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:
parent
18218ab023
commit
ab1bf189b3
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
MAJOR=1
|
||||
MINOR=16
|
||||
PATCH=3
|
||||
CODE=23
|
||||
PATCH=5
|
||||
CODE=25
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user