feat: Implement item selection mode with contextual actions and fix build errors

This commit is contained in:
2025-10-11 14:37:30 +02:00
parent f74da803ca
commit 2f088ad9c2
4 changed files with 173 additions and 423 deletions

View File

@@ -183,7 +183,11 @@ fun AppShell(
is Screen.ShoppingListDetail -> { // New case for detail screen
ShoppingListDetailScreen(
listId = selectedListId,
viewModel = shoppingListsViewModel
viewModel = shoppingListsViewModel,
navigateBack = {
currentScreen = Screen.ShoppingLists
selectedListId = null
}
)
}
}

View File

@@ -1,25 +1,29 @@
package de.lxtools.noteshop.ui.shopping
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Delete
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LooksOne
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -29,12 +33,15 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.collectAsState
import androidx.compose.runtime.getValue
@@ -44,661 +51,386 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
import de.lxtools.noteshop.data.ShoppingListItem
import kotlinx.coroutines.launch
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShoppingListDetailScreen(
listId: Int?,
viewModel: ShoppingListsViewModel,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
navigateBack: () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var newItemName by remember { mutableStateOf("") }
val showCompletedItems by viewModel.showCompletedItems.collectAsState()
val shoppingListWithItems by viewModel.getShoppingListWithItemsStream(listId ?: 0)
.collectAsState(initial = null)
var selectedItem by remember { mutableStateOf<ShoppingListItem?>(null) }
var showRenameDialog by remember { mutableStateOf(false) }
var showQuantityDialog by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
if (selectedItem != null) {
TopAppBar(
title = { Text(text = selectedItem!!.name) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
navigationIcon = {
IconButton(onClick = { selectedItem = null }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = {
coroutineScope.launch {
listId?.let { selectedItem?.let { item -> viewModel.moveItemToTop(it, item) } }
selectedItem = null // Exit selection mode after move
}
}) {
Icon(Icons.Filled.ArrowUpward, contentDescription = stringResource(R.string.move_to_top))
}
IconButton(onClick = {
coroutineScope.launch {
listId?.let { selectedItem?.let { item -> viewModel.moveItemToBottom(it, item) } }
selectedItem = null // Exit selection mode after move
}
}) {
Icon(Icons.Filled.ArrowDownward, contentDescription = stringResource(R.string.move_to_bottom))
}
IconButton(onClick = { showRenameDialog = true }) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
}
}
)
} else {
TopAppBar(
title = { Text(text = shoppingListWithItems?.shoppingList?.name ?: stringResource(R.string.menu_shopping_list_detail)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
}
)
}
},
bottomBar = {
if (shoppingListWithItems != null) {
val list = shoppingListWithItems!!
BottomAppBar(
modifier = Modifier.imePadding()
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
IconButton( // Default Add Button
onClick = {
if (newItemName.isNotBlank()) {
coroutineScope.launch {
val existingItemNames = list.items.map { it.name.trim().lowercase() }
val itemsToAdd = if (newItemName.contains(',')) {
newItemName.split(',').map { it.trim() }.filter { it.isNotBlank() && !existingItemNames.contains(it.lowercase()) }
} else {
if (!existingItemNames.contains(newItemName.trim().lowercase())) {
listOf(newItemName.trim())
} else {
emptyList()
}
}
itemsToAdd.forEach { name ->
viewModel.saveShoppingListItem(
ShoppingListItem(
name = name,
listId = list.shoppingList.id
)
)
}
newItemName = "" // Clear input field
}
}
},
enabled = newItemName.isNotBlank()
) {
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = stringResource(R.string.add_item_icon_desc)
)
}
IconButton( // Simple Mode Add Button
onClick = {
if (newItemName.isNotBlank()) {
coroutineScope.launch {
val existingItemNames = list.items.map { it.name.trim().lowercase() }
if (!existingItemNames.contains(newItemName.trim().lowercase())) {
viewModel.saveShoppingListItem(
ShoppingListItem(
name = newItemName.trim(),
listId = list.shoppingList.id
)
)
}
newItemName = "" // Clear input field
}
}
},
enabled = newItemName.isNotBlank()
) {
Icon(
Icons.Filled.LooksOne,
contentDescription = stringResource(R.string.add_item_simple_icon_desc)
)
}
IconButton(onClick = { viewModel.toggleShowCompletedItems() }) {
Icon(
imageVector = if (showCompletedItems) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showCompletedItems) stringResource(R.string.hide_completed_icon_desc) else stringResource(
R.string.show_completed_icon_desc
)
)
}
IconButton(
onClick = {
coroutineScope.launch {
listId?.let { viewModel.deleteCompletedItems(it) }
}
},
enabled = list.items.any { it.isChecked }
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.remove_completed_icon_desc)
)
}
}
}
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp)
) {
if (shoppingListWithItems == null) {
Text(
text = stringResource(R.string.shopping_list_not_found),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
} else {
val list = shoppingListWithItems!!
Text(
text = list.shoppingList.name,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = newItemName,
onValueChange = { newItemName = it },
label = { Text(stringResource(R.string.add_item_hint)) },
modifier = Modifier.fillMaxWidth()
)
if (newItemName.isNotBlank()) {
val suggestions = shoppingListWithItems?.items?.filter {
it.name.contains(newItemName, ignoreCase = true) && it.isChecked
} ?: emptyList()
if (suggestions.isNotEmpty()) {
LazyColumn(modifier = Modifier.heightIn(max = 150.dp)) {
items(suggestions) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable {
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = false))
newItemName = ""
}
},
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = item.name + " (" + stringResource(R.string.completed) + ")",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
if (list.items.isEmpty()) {
Text(text = stringResource(R.string.no_items_in_list))
} else {
val filteredItems = if (showCompletedItems) {
list.items
} else {
list.items.filter { !it.isChecked } // Filter out checked items
}.sortedBy { it.displayOrder }
} else {
val filteredItems = if (showCompletedItems) {
list.items
} else {
list.items.filter { !it.isChecked } // Filter out checked items
}.sortedBy { it.displayOrder }
if (filteredItems.isEmpty() && !showCompletedItems) {
Text(text = stringResource(R.string.no_uncompleted_items))
} else if (filteredItems.isEmpty() && showCompletedItems) {
Text(text = stringResource(R.string.no_items_in_list))
} else {
val state = rememberReorderableLazyListState(onMove = { from, to ->
listId?.let { viewModel.moveItem(it, from.index, to.index) }
})
LazyColumn(
state = state.listState,
modifier = Modifier.reorderable(state)
modifier = Modifier
) {
items(filteredItems, { it.id }) { item ->
ReorderableItem(state, key = item.id) {
var showMenu by remember { mutableStateOf(false) }
var showRenameDialog by remember { mutableStateOf(false) }
var showQuantityDialog by remember { mutableStateOf(false) }
if (showRenameDialog) {
RenameItemDialog(
item = item,
onDismiss = { showRenameDialog = false },
onRename = { newName ->
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(name = newName))
}
showRenameDialog = false
}
)
}
if (showQuantityDialog) {
QuantityDialog(
item = item,
onDismiss = { showQuantityDialog = false },
onSetQuantity = { quantity ->
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(quantity = quantity))
}
showQuantityDialog = false
}
)
}
Box(
val isSelected = item == selectedItem
Box(
modifier = Modifier
.background(if (isSelected) Color.LightGray else Color.Transparent)
) {
Row(
modifier = Modifier
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
showMenu = true
}
)
},
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.detectReorderAfterLongPress(state),
onClick = { /* Drag handle, no direct click action */ }
) {
Icon(Icons.Default.DragHandle, contentDescription = "Drag handle")
}
Checkbox(
checked = item.isChecked,
onCheckedChange = { isChecked ->
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = isChecked))
.fillMaxWidth()
.padding(vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
selectedItem = item
}
)
},
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isChecked,
onCheckedChange = { isChecked ->
coroutineScope.launch {
viewModel.saveShoppingListItem(
item.copy(
isChecked = isChecked
)
)
}
)
Text(
text = if (item.quantity != null) "${item.name} (${item.quantity})" else item.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.rename)) },
onClick = {
showMenu = false
showRenameDialog = true
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.change_quantity)) },
onClick = {
showMenu = false
showQuantityDialog = true
}
)
}
}
)
Text(
text = if (item.quantity != null) "${item.name} (${item.quantity})" else item.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
}
}
}
if (showRenameDialog && selectedItem != null) {
RenameItemDialog(
item = selectedItem!!,
onDismiss = { showRenameDialog = false },
onRename = { newName ->
coroutineScope.launch {
viewModel.saveShoppingListItem(selectedItem!!.copy(name = newName))
}
showRenameDialog = false
selectedItem = null // Exit selection mode after rename
}
)
}
if (showQuantityDialog && selectedItem != null) {
QuantityDialog(
item = selectedItem!!,
onDismiss = { showQuantityDialog = false },
onSetQuantity = { quantity ->
coroutineScope.launch {
viewModel.saveShoppingListItem(selectedItem!!.copy(quantity = quantity))
}
showQuantityDialog = false
selectedItem = null // Exit selection mode after quantity change
}
)
}
}
@Composable
fun RenameItemDialog(item: ShoppingListItem, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var text by remember { mutableStateOf(item.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rename_item_title)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(R.string.new_name)) }
)
},
confirmButton = {
Button(
onClick = { onRename(text) }
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
@Composable
fun QuantityDialog(item: ShoppingListItem, onDismiss: () -> Unit, onSetQuantity: (String?) -> Unit) {
var text by remember { mutableStateOf(item.quantity ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.change_quantity)) },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text(stringResource(R.string.quantity_hint)) }
)
},
confirmButton = {
Button(
onClick = { onSetQuantity(text.ifBlank { null }) }
) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
}
)
}
}

View File

@@ -106,25 +106,6 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
}
}
fun moveItem(listId: Int, from: Int, to: Int) {
viewModelScope.launch {
val currentListWithItems = noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()
currentListWithItems?.let {
val items = it.items.toMutableList()
// Ensure 'from' and 'to' indices are within bounds
if (from >= 0 && from < items.size && to >= 0 && to < items.size) {
val movedItem = items.removeAt(from)
items.add(to, movedItem)
val updatedItems = items.mapIndexed { index, item ->
item.copy(displayOrder = index)
}
noteshopRepository.updateShoppingListItems(updatedItems)
}
}
}
}
suspend fun deleteItem(item: ShoppingListItem) {
noteshopRepository.deleteShoppingListItem(item)
}
@@ -166,6 +147,36 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
}
}
suspend fun moveItemToTop(listId: Int, itemToMove: ShoppingListItem) {
noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.let { listWithItems ->
val currentItems = listWithItems.items.toMutableList()
val index = currentItems.indexOfFirst { it.id == itemToMove.id }
if (index > 0) {
currentItems.removeAt(index)
currentItems.add(0, itemToMove)
val updatedItems = currentItems.mapIndexed { idx, item ->
item.copy(displayOrder = idx)
}
noteshopRepository.updateShoppingListItems(updatedItems)
}
}
}
suspend fun moveItemToBottom(listId: Int, itemToMove: ShoppingListItem) {
noteshopRepository.getShoppingListWithItemsStream(listId).firstOrNull()?.let { listWithItems ->
val currentItems = listWithItems.items.toMutableList()
val index = currentItems.indexOfFirst { it.id == itemToMove.id }
if (index != -1 && index < currentItems.size - 1) {
currentItems.removeAt(index)
currentItems.add(currentItems.size, itemToMove)
val updatedItems = currentItems.mapIndexed { idx, item ->
item.copy(displayOrder = idx)
}
noteshopRepository.updateShoppingListItems(updatedItems)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}

View File

@@ -47,4 +47,7 @@
<string name="rename_item_title">Rename Item</string>
<string name="new_name">New Name</string>
<string name="quantity_hint">Quantity (e.g., 1kg, 2 pieces)</string>
<string name="back">Back</string>
<string name="move_to_top">Move to Top</string>
<string name="move_to_bottom">Move to Bottom</string>
</resources>