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,
|
ShoppingListItemEntity::class,
|
||||||
ShoppingListMemberEntity::class
|
ShoppingListMemberEntity::class
|
||||||
],
|
],
|
||||||
version = 6,
|
version = 7,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,18 +273,20 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
MAJOR=1
|
MAJOR=1
|
||||||
MINOR=16
|
MINOR=16
|
||||||
PATCH=3
|
PATCH=5
|
||||||
CODE=23
|
CODE=25
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user