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,
ShoppingListMemberEntity::class
],
version = 6,
version = 7,
exportSchema = false
)
@TypeConverters(Converters::class)

View File

@ -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(

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(
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(

View File

@ -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<ShoppingListItemUi>,
val recentlyUsed: List<ShoppingListItemUi>
) : UiState()
@ -117,14 +119,13 @@ class ListDetailViewModel @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<UiState> = _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 }

View File

@ -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

View File

@ -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<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(
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)
)
}
}
}

View File

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