From ab1bf189b3163dd956598b763842fd2214e41b46 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 28 Apr 2026 11:03:44 -0400 Subject: [PATCH] feat: add drag-to-reorder category sorting with live preview in shopping list settings, persist custom category order to database - Add `categoryOrder` field to `ShoppingListEntity` for storing custom category sequences - Increment database version to 7 - Implement long-press drag gesture to reorder categories in `ListSortScreen` with visual feedback - Add live preview card showing ordered categories with visibility indicators - Apply custom category order in `ListDetailScreen` when rendering item groups - Update `ListDetailViewModel --- .../data/local/database/SafeBiteDatabase.kt | 2 +- .../data/local/database/entity/Entities.kt | 3 +- .../screen/lists/ListDetailScreen.kt | 10 +- .../screen/lists/ListDetailViewModel.kt | 11 +- .../screen/lists/ListsViewModel.kt | 6 + .../screen/lists/settings/ListSortScreen.kt | 202 +++++++++++++----- version.properties | 4 +- 7 files changed, 175 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt index f7850ae..b47c693 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/SafeBiteDatabase.kt @@ -23,7 +23,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity ShoppingListItemEntity::class, ShoppingListMemberEntity::class ], - version = 6, + version = 7, exportSchema = false ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt index 0856632..2d0b643 100644 --- a/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt +++ b/app/src/main/java/com/safebite/app/data/local/database/entity/Entities.kt @@ -76,7 +76,8 @@ data class ShoppingListEntity( val region: String? = null, val sortType: String = "category", val displayOrder: Int = 0, - val visibleCategories: String? = null + val visibleCategories: String? = null, + val categoryOrder: String? = null ) @Entity( diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt index 2ffd2b3..95d5773 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailScreen.kt @@ -418,6 +418,14 @@ private fun ListDetailContent( } } + val orderedCategories = remember(ready.list) { + val order = ready.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() } + val visible = ready.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet() + val base = order ?: catalog.categories + val filtered = if (visible != null) base.filter { it in visible } else base + filtered.filter { it in catalog.categories } + } + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -499,7 +507,7 @@ private fun ListDetailContent( ) } - catalog.categories.forEach { category -> + orderedCategories.forEach { category -> val expanded = expandedCategories[category] ?: false item(key = "header-$category") { CollapsibleHeader( diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt index 1a1fe08..bc2e07e 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListDetailViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -47,6 +48,7 @@ class ListDetailViewModel @Inject constructor( data class Ready( val listId: Long, val listName: String, + val list: ShoppingListEntity?, val activeItems: List, val recentlyUsed: List ) : UiState() @@ -117,14 +119,13 @@ class ListDetailViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) val state: StateFlow = _listIdFlow.flatMapLatest { listId -> - combine( - manageListUseCase.observeItems(listId), - _listName - ) { items, listName -> + manageListUseCase.observeItems(listId).map { items -> + val list = getListsUseCase.getList(listId) val ui = items.map { it.toUi() } UiState.Ready( listId = listId, - listName = listName, + listName = list?.name ?: _listName.value, + list = list, activeItems = ui.filterNot { it.isChecked } .sortedBy { it.productName.lowercase() }, recentlyUsed = ui.filter { it.isChecked } diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt index c84d46d..f1c8c48 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/ListsViewModel.kt @@ -86,6 +86,12 @@ class ListsViewModel @Inject constructor( _isEditMode.value = !_isEditMode.value } + fun updateList(list: ShoppingListEntity) { + viewModelScope.launch { + getShoppingListsUseCase.updateList(list) + } + } + fun reorderLists(fromIndex: Int, toIndex: Int) { viewModelScope.launch { val current = state.value as? UiState.Success ?: return@launch diff --git a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt index 72179cd..e1b820c 100644 --- a/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt +++ b/app/src/main/java/com/safebite/app/presentation/screen/lists/settings/ListSortScreen.kt @@ -2,6 +2,7 @@ package com.safebite.app.presentation.screen.lists.settings import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,11 +10,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -21,7 +23,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Card @@ -34,23 +35,30 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.safebite.app.R import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.presentation.screen.lists.ListsViewModel +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,12 +70,24 @@ fun ListSortScreen( val state by viewModel.state.collectAsStateWithLifecycle() val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val catalog = remember { CatalogProvider() } - var visibleCategories by remember(listData?.list?.visibleCategories) { - mutableStateOf( - listData?.list?.visibleCategories?.split(",")?.toSet() ?: catalog.categories.toSet() - ) + + val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() } + val orderedCategories = remember(listData?.list?.categoryOrder) { + mutableStateListOf().apply { + addAll(savedOrder ?: catalog.categories) + } } + val savedVisible = listData?.list?.visibleCategories?.split(",")?.filter { it.isNotBlank() }?.toSet() + var visibleCategories by remember(listData?.list?.visibleCategories) { + mutableStateOf(savedVisible ?: catalog.categories.toSet()) + } + + var draggedIndex by remember { mutableStateOf(null) } + var dragOffsetY by remember { mutableFloatStateOf(0f) } + val itemHeight = 56.dp + val itemPx = with(LocalContext.current.resources.displayMetrics) { itemHeight.value * density } + Scaffold( topBar = { TopAppBar( @@ -87,10 +107,12 @@ fun ListSortScreen( .padding(end = 16.dp) .clickable { listData?.let { - val updated = it.list.copy( - visibleCategories = visibleCategories.joinToString(",") + viewModel.updateList( + it.list.copy( + visibleCategories = visibleCategories.joinToString(","), + categoryOrder = orderedCategories.joinToString(",") + ) ) - // TODO: update via viewmodel/usecase } onBack() } @@ -112,6 +134,7 @@ fun ListSortScreen( modifier = Modifier.padding(vertical = 8.dp) ) + // Preview card Card( modifier = Modifier .fillMaxWidth() @@ -121,23 +144,49 @@ fun ListSortScreen( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(16.dp) ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Sort, - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = stringResource(R.string.list_sort_preview), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Sort, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.list_sort_preview), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + orderedCategories.forEach { category -> + val isVisible = category in visibleCategories + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isVisible) "●" else "○", + style = MaterialTheme.typography.bodySmall, + color = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = category, + style = MaterialTheme.typography.bodySmall, + color = if (isVisible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } } @@ -149,45 +198,92 @@ fun ListSortScreen( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(vertical = 8.dp) ) { - itemsIndexed(catalog.categories) { _, category -> - val isVisible = category in visibleCategories - Row( + itemsIndexed( + items = orderedCategories, + key = { _, item -> item } + ) { index, category -> + val isDragged = draggedIndex == index + val zIndex = if (isDragged) 1f else 0f + val offsetY = if (isDragged) dragOffsetY.roundToInt() else 0 + + Box( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { + .zIndex(zIndex) + .offset { IntOffset(0, offsetY) } + .graphicsLayer { + scaleX = if (isDragged) 1.02f else 1f + scaleY = if (isDragged) 1.02f else 1f + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { draggedIndex = index }, + onDragEnd = { + draggedIndex?.let { from -> + val to = (from + (dragOffsetY / itemPx).roundToInt()) + .coerceIn(0, orderedCategories.size - 1) + if (from != to) { + val moved = orderedCategories.removeAt(from) + orderedCategories.add(to, moved) + } + } + draggedIndex = null + dragOffsetY = 0f + }, + onDragCancel = { + draggedIndex = null + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetY += dragAmount.y + } + ) + } + ) { + val isVisible = category in visibleCategories + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + if (isDragged) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface + ) + .clickable { + visibleCategories = if (isVisible) { + visibleCategories - category + } else { + visibleCategories + category + } + } + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = category, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { visibleCategories = if (isVisible) { visibleCategories - category } else { visibleCategories + category } + }) { + Icon( + imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, + contentDescription = if (isVisible) "Masquer" else "Afficher", + tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) } - .padding(vertical = 12.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = category, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - IconButton(onClick = { - visibleCategories = if (isVisible) { - visibleCategories - category - } else { - visibleCategories + category - } - }) { Icon( - imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, - contentDescription = null, - tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + imageVector = Icons.Filled.DragHandle, + contentDescription = "Réordonner", + modifier = Modifier.padding(start = 8.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - Icon( - imageVector = Icons.Filled.DragHandle, - contentDescription = null, - modifier = Modifier.padding(start = 8.dp) - ) } } } diff --git a/version.properties b/version.properties index 6419027..d79e603 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ MAJOR=1 MINOR=16 -PATCH=3 -CODE=23 +PATCH=5 +CODE=25