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
This commit is contained in:
Bruno Charest 2026-04-28 11:03:44 -04:00
parent 18218ab023
commit ab1bf189b3
7 changed files with 175 additions and 63 deletions

View File

@ -23,7 +23,7 @@ import com.safebite.app.data.local.database.entity.UserProfileEntity
ShoppingListItemEntity::class, ShoppingListItemEntity::class,
ShoppingListMemberEntity::class ShoppingListMemberEntity::class
], ],
version = 6, version = 7,
exportSchema = false exportSchema = false
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)

View File

@ -76,7 +76,8 @@ data class ShoppingListEntity(
val region: String? = null, val region: String? = null,
val sortType: String = "category", val sortType: String = "category",
val displayOrder: Int = 0, val displayOrder: Int = 0,
val visibleCategories: String? = null val visibleCategories: String? = null,
val categoryOrder: String? = null
) )
@Entity( @Entity(

View File

@ -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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
@ -499,7 +507,7 @@ private fun ListDetailContent(
) )
} }
catalog.categories.forEach { category -> orderedCategories.forEach { category ->
val expanded = expandedCategories[category] ?: false val expanded = expandedCategories[category] ?: false
item(key = "header-$category") { item(key = "header-$category") {
CollapsibleHeader( CollapsibleHeader(

View File

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -47,6 +48,7 @@ class ListDetailViewModel @Inject constructor(
data class Ready( data class Ready(
val listId: Long, val listId: Long,
val listName: String, val listName: String,
val list: ShoppingListEntity?,
val activeItems: List<ShoppingListItemUi>, val activeItems: List<ShoppingListItemUi>,
val recentlyUsed: List<ShoppingListItemUi> val recentlyUsed: List<ShoppingListItemUi>
) : UiState() ) : UiState()
@ -117,14 +119,13 @@ class ListDetailViewModel @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId -> val state: StateFlow<UiState> = _listIdFlow.flatMapLatest { listId ->
combine( manageListUseCase.observeItems(listId).map { items ->
manageListUseCase.observeItems(listId), val list = getListsUseCase.getList(listId)
_listName
) { items, listName ->
val ui = items.map { it.toUi() } val ui = items.map { it.toUi() }
UiState.Ready( UiState.Ready(
listId = listId, listId = listId,
listName = listName, listName = list?.name ?: _listName.value,
list = list,
activeItems = ui.filterNot { it.isChecked } activeItems = ui.filterNot { it.isChecked }
.sortedBy { it.productName.lowercase() }, .sortedBy { it.productName.lowercase() },
recentlyUsed = ui.filter { it.isChecked } recentlyUsed = ui.filter { it.isChecked }

View File

@ -86,6 +86,12 @@ class ListsViewModel @Inject constructor(
_isEditMode.value = !_isEditMode.value _isEditMode.value = !_isEditMode.value
} }
fun updateList(list: ShoppingListEntity) {
viewModelScope.launch {
getShoppingListsUseCase.updateList(list)
}
}
fun reorderLists(fromIndex: Int, toIndex: Int) { fun reorderLists(fromIndex: Int, toIndex: Int) {
viewModelScope.launch { viewModelScope.launch {
val current = state.value as? UiState.Success ?: return@launch val current = state.value as? UiState.Success ?: return@launch

View File

@ -2,6 +2,7 @@ package com.safebite.app.presentation.screen.lists.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape 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.automirrored.filled.Sort
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.DragHandle 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -34,23 +35,30 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.safebite.app.R import com.safebite.app.R
import com.safebite.app.domain.engine.CatalogProvider import com.safebite.app.domain.engine.CatalogProvider
import com.safebite.app.presentation.screen.lists.ListsViewModel import com.safebite.app.presentation.screen.lists.ListsViewModel
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -62,11 +70,23 @@ fun ListSortScreen(
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId } val listData = (state as? ListsViewModel.UiState.Success)?.lists?.firstOrNull { it.list.id == listId }
val catalog = remember { CatalogProvider() } val catalog = remember { CatalogProvider() }
var visibleCategories by remember(listData?.list?.visibleCategories) {
mutableStateOf( val savedOrder = listData?.list?.categoryOrder?.split(",")?.filter { it.isNotBlank() }
listData?.list?.visibleCategories?.split(",")?.toSet() ?: catalog.categories.toSet() val orderedCategories = remember(listData?.list?.categoryOrder) {
) mutableStateListOf<String>().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<Int?>(null) }
var dragOffsetY by remember { mutableFloatStateOf(0f) }
val itemHeight = 56.dp
val itemPx = with(LocalContext.current.resources.displayMetrics) { itemHeight.value * density }
Scaffold( Scaffold(
topBar = { topBar = {
@ -87,10 +107,12 @@ fun ListSortScreen(
.padding(end = 16.dp) .padding(end = 16.dp)
.clickable { .clickable {
listData?.let { listData?.let {
val updated = it.list.copy( viewModel.updateList(
visibleCategories = visibleCategories.joinToString(",") it.list.copy(
visibleCategories = visibleCategories.joinToString(","),
categoryOrder = orderedCategories.joinToString(",")
)
) )
// TODO: update via viewmodel/usecase
} }
onBack() onBack()
} }
@ -112,6 +134,7 @@ fun ListSortScreen(
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
// Preview card
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -121,10 +144,13 @@ fun ListSortScreen(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) )
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@ -139,6 +165,29 @@ fun ListSortScreen(
fontWeight = FontWeight.SemiBold 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
)
}
}
}
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -149,12 +198,57 @@ fun ListSortScreen(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
itemsIndexed(catalog.categories) { _, category -> 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
.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 val isVisible = category in visibleCategories
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(
if (isDragged) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
)
.clickable { .clickable {
visibleCategories = if (isVisible) { visibleCategories = if (isVisible) {
visibleCategories - category visibleCategories - category
@ -179,14 +273,15 @@ fun ListSortScreen(
}) { }) {
Icon( Icon(
imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff, imageVector = if (isVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
contentDescription = null, contentDescription = if (isVisible) "Masquer" else "Afficher",
tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant tint = if (isVisible) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
Icon( Icon(
imageVector = Icons.Filled.DragHandle, imageVector = Icons.Filled.DragHandle,
contentDescription = null, contentDescription = "Réordonner",
modifier = Modifier.padding(start = 8.dp) modifier = Modifier.padding(start = 8.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@ -194,3 +289,4 @@ fun ListSortScreen(
} }
} }
} }
}

View File

@ -1,4 +1,4 @@
MAJOR=1 MAJOR=1
MINOR=16 MINOR=16
PATCH=3 PATCH=5
CODE=23 CODE=25