feat(ui): Implement reorder mode for shopping list items

Implements a "reorder mode" for the shopping list detail screen to resolve the conflict between scrolling and drag-to-reorder gestures.

- A long press on a list item now activates reorder mode.
- In this mode, a drag handle is shown on each item, and list items can be reordered. Regular clicks for checking items are disabled.
- The back button deactivates the reorder mode.
- Gesture handling has been consolidated to correctly detect both taps and long presses on list items.
This commit is contained in:
2025-10-12 21:49:07 +02:00
parent 04876d1860
commit 69cdc03277
4 changed files with 105 additions and 129 deletions

View File

@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete
@@ -110,6 +111,9 @@ fun AppShell(
var currentScreen: Screen by remember { mutableStateOf(Screen.ShoppingLists) }
var selectedListId: Int? by remember { mutableStateOf(null) }
var showListDialog by remember { mutableStateOf(false) }
val listDetails by shoppingListsViewModel.listDetails.collectAsState()
val navigationItems = listOf(
Screen.ShoppingLists,
Screen.Notes
@@ -174,42 +178,14 @@ fun AppShell(
TopAppBar(
title = { Text(topBarTitle) },
navigationIcon = {
IconButton(onClick = { currentScreen = Screen.ShoppingLists; selectedListId = null }) {
IconButton(onClick = {
shoppingListsViewModel.disableReorderMode()
currentScreen = Screen.ShoppingLists; selectedListId = null
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { scope.launch { selectedItem?.let { shoppingListsViewModel.moveItemUp(selectedListId ?: 0, it) }; shoppingListsViewModel.onSelectItem(null) } }, enabled = selectedItem != null,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
scope.launch { selectedItem?.let { shoppingListsViewModel.moveItemToTop(selectedListId ?: 0, it) } }
}
)
}
) {
Icon(Icons.Default.ArrowUpward, contentDescription = stringResource(R.string.move_item_up))
}
IconButton(onClick = { scope.launch { selectedItem?.let { shoppingListsViewModel.moveItemDown(selectedListId ?: 0, it) } }; shoppingListsViewModel.onSelectItem(null) }, enabled = selectedItem != null,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
scope.launch { selectedItem?.let { shoppingListsViewModel.moveItemToBottom(selectedListId ?: 0, it) } }
}
)
}
) {
Icon(Icons.Default.ArrowDownward, contentDescription = stringResource(R.string.move_item_down))
}
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu }, enabled = selectedItem != null) {
Icon(Icons.Default.MoreVert, contentDescription = stringResource(R.string.more_options))
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.rename)) }, onClick = { shoppingListsViewModel.onShowRenameDialog(true); showMenu = false })
DropdownMenuItem(text = { Text(stringResource(R.string.change_quantity)) }, onClick = { shoppingListsViewModel.onShowQuantityDialog(true); showMenu = false })
}
},
actions = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
@@ -222,12 +198,21 @@ fun AppShell(
Icon(imageVector = Icons.Default.Menu, contentDescription = stringResource(id = R.string.menu_open))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
actions = {
if (currentScreen == Screen.ShoppingLists) {
IconButton(onClick = {
shoppingListsViewModel.resetListDetails()
showListDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_shopping_list))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
if (currentScreen == Screen.ShoppingLists) {
OutlinedTextField(
value = searchQuery,
@@ -268,6 +253,22 @@ fun AppShell(
}
}
) { innerPadding ->
if (showListDialog) {
de.lxtools.noteshop.ui.shopping.ShoppingListInputDialog(
listDetails = listDetails,
onValueChange = shoppingListsViewModel::updateListDetails,
onSave = {
scope.launch {
shoppingListsViewModel.saveList()
showListDialog = false
shoppingListsViewModel.resetListDetails()
}
},
onDismiss = { showListDialog = false },
isNewList = listDetails.id == 0
)
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -281,6 +282,10 @@ fun AppShell(
onListClick = { listId ->
selectedListId = listId
currentScreen = Screen.ShoppingListDetail
},
onEditList = { listWithItems ->
shoppingListsViewModel.updateListDetails(listWithItems.shoppingList)
showListDialog = true
}
)
}

View File

@@ -44,6 +44,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
@@ -70,6 +72,7 @@ fun ShoppingListDetailScreen(
val selectedItem by viewModel.selectedItem.collectAsState()
val showRenameDialog by viewModel.showRenameDialog.collectAsState()
val showQuantityDialog by viewModel.showQuantityDialog.collectAsState()
val isReorderMode by viewModel.isReorderMode.collectAsState()
Column(
modifier = modifier
@@ -152,14 +155,25 @@ fun ShoppingListDetailScreen(
) {
items(filteredItems, { it.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val elevation = animateDpAsState(if (isDragging) 4.dp else 0.dp, label = "elevation")
val elevation = animateDpAsState(if (isDragging) 4.dp else 1.dp, label = "elevation")
val scale = animateFloatAsState(if (isDragging) 1.05f else 1.0f, label = "scale")
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.draggableHandle()
.pointerInput(isReorderMode) {
if (!isReorderMode) {
detectTapGestures(
onLongPress = { viewModel.enableReorderMode() },
onTap = {
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = !item.isChecked))
}
}
)
}
}
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
@@ -169,14 +183,14 @@ fun ShoppingListDetailScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { // The check/uncheck logic
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = !item.isChecked))
}
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (isReorderMode) {
IconButton(modifier = Modifier.draggableHandle(), onClick = {}) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
Text(
text = if (item.quantity != null) "${item.name} (${item.quantity})" else item.name,
style = MaterialTheme.typography.bodyLarge,

View File

@@ -62,98 +62,44 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
fun ShoppingListsScreen(
viewModel: ShoppingListsViewModel = viewModel(factory = AppViewModelProvider.Factory),
modifier: Modifier = Modifier,
onListClick: (Int) -> Unit // New parameter
onListClick: (Int) -> Unit,
onEditList: (ShoppingListWithItems) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val listDetails by viewModel.listDetails.collectAsState()
val itemDetails by viewModel.itemDetails.collectAsState()
var showListDialog by remember { mutableStateOf(false) }
var showItemDialog by remember { mutableStateOf(false) }
var currentSelectedListId by remember { mutableStateOf(0) }
val coroutineScope = rememberCoroutineScope()
Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = {
viewModel.resetListDetails()
showListDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.add_shopping_list))
Column(
modifier = modifier.fillMaxSize()
) {
if (uiState.shoppingLists.isEmpty()) {
Text(text = stringResource(R.string.no_shopping_lists_yet))
} else {
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
viewModel.moveList(from.index, to.index)
}
}
) { innerPadding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(innerPadding)
) {
if (uiState.shoppingLists.isEmpty()) {
Text(text = stringResource(R.string.no_shopping_lists_yet))
} else {
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
viewModel.moveList(from.index, to.index)
}
LazyColumn(
state = lazyListState,
) {
items(uiState.shoppingLists, { it.shoppingList.id }) { listWithItems ->
ReorderableItem(reorderableLazyListState, key = listWithItems.shoppingList.id) { isDragging ->
ShoppingListCard(
listWithItems = listWithItems,
onEditList = {
viewModel.updateListDetails(it.shoppingList)
showListDialog = true
},
onDeleteList = {
coroutineScope.launch {
viewModel.deleteList(it.shoppingList)
}
},
modifier = Modifier
.clickable { onListClick(listWithItems.shoppingList.id) },
scope = this,
isDragging = isDragging
)
}
LazyColumn(
state = lazyListState,
) {
items(uiState.shoppingLists, { it.shoppingList.id }) { listWithItems ->
ReorderableItem(reorderableLazyListState, key = listWithItems.shoppingList.id) { isDragging ->
ShoppingListCard(
listWithItems = listWithItems,
onEditList = onEditList,
onDeleteList = {
coroutineScope.launch {
viewModel.deleteList(it.shoppingList)
}
},
modifier = Modifier
.clickable { onListClick(listWithItems.shoppingList.id) },
scope = this,
isDragging = isDragging
)
}
}
}
}
if (showListDialog) {
ShoppingListInputDialog(
listDetails = listDetails,
onValueChange = viewModel::updateListDetails,
onSave = {
coroutineScope.launch {
viewModel.saveList()
showListDialog = false
viewModel.resetListDetails()
}
},
onDismiss = { showListDialog = false },
isNewList = listDetails.id == 0
)
}
if (showItemDialog) {
ShoppingListItemInputDialog(
itemDetails = itemDetails,
onValueChange = viewModel::updateItemDetails,
onSave = {
coroutineScope.launch {
viewModel.saveShoppingListItem(itemDetails.toShoppingListItem())
showItemDialog = false
viewModel.resetItemDetails(currentSelectedListId)
}
},
onDismiss = { showItemDialog = false },
isNewItem = itemDetails.id == 0
)
}
}
}

View File

@@ -38,6 +38,9 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
private val _showQuantityDialog = MutableStateFlow(false)
val showQuantityDialog: StateFlow<Boolean> = _showQuantityDialog.asStateFlow()
private val _isReorderMode = MutableStateFlow(false)
val isReorderMode: StateFlow<Boolean> = _isReorderMode.asStateFlow()
fun onNewItemNameChange(name: String) {
_newItemName.value = name
}
@@ -54,6 +57,14 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
_showQuantityDialog.value = show
}
fun enableReorderMode() {
_isReorderMode.value = true
}
fun disableReorderMode() {
_isReorderMode.value = false
}
val uiState: StateFlow<ShoppingListsUiState> = combine(
noteshopRepository.getAllShoppingListsWithItemsStream(),
searchQuery