feat: Implement drag-and-drop reordering for shopping list items and externalize UI strings

This commit is contained in:
2025-10-11 12:43:32 +02:00
parent 333b43abe0
commit f74da803ca
7 changed files with 580 additions and 197 deletions

View File

@@ -10,7 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [Note::class, ShoppingList::class, ShoppingListItem::class],
version = 3, // Incremented version
version = 4, // Incremented version
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -31,7 +31,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"noteshop_database"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
INSTANCE = instance
// return instance
@@ -50,5 +50,11 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE shopping_lists ADD COLUMN displayOrder INTEGER NOT NULL DEFAULT 0")
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE shopping_list_items ADD COLUMN displayOrder INTEGER NOT NULL DEFAULT 0")
}
}
}
}

View File

@@ -71,11 +71,21 @@ interface NoteshopRepository {
*/
suspend fun deleteShoppingListItem(item: ShoppingListItem)
/**
* Update multiple shopping list items in the data source
*/
suspend fun updateShoppingListItems(items: List<ShoppingListItem>)
/**
* Get the number of shopping lists.
*/
suspend fun getListsCount(): Int
/**
* Get the number of items in a specific shopping list.
*/
suspend fun getItemsCount(listId: Int): Int
}
class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppingListDao: ShoppingListDao) : NoteshopRepository {
@@ -107,4 +117,8 @@ class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppi
override suspend fun getListsCount(): Int = shoppingListDao.getListsCount()
override suspend fun getItemsCount(listId: Int): Int = shoppingListDao.getItemsCount(listId)
override suspend fun updateShoppingListItems(items: List<ShoppingListItem>) = shoppingListDao.updateItems(items)
}

View File

@@ -35,6 +35,12 @@ interface ShoppingListDao {
@Delete
suspend fun deleteItem(item: ShoppingListItem)
@Update
suspend fun updateItems(items: List<ShoppingListItem>)
@Query("SELECT COUNT(*) FROM shopping_list_items WHERE list_id = :listId")
suspend fun getItemsCount(listId: Int): Int
// --- Methods for combined data ---
@Transaction
@Query("SELECT * FROM shopping_lists ORDER BY displayOrder ASC")

View File

@@ -24,5 +24,6 @@ data class ShoppingListItem(
@ColumnInfo(name = "is_checked")
val isChecked: Boolean = false,
@ColumnInfo(name = "list_id", index = true)
val listId: Int
val listId: Int,
val displayOrder: Int = 0
)

View File

@@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
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.LooksOne
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -49,332 +50,655 @@ 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
@Composable
fun ShoppingListDetailScreen(
listId: Int?,
viewModel: ShoppingListsViewModel,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
var newItemName by remember { mutableStateOf("") }
val showCompletedItems by viewModel.showCompletedItems.collectAsState()
val shoppingListWithItems by viewModel.getShoppingListWithItemsStream(listId ?: 0)
.collectAsState(initial = null)
Scaffold(
modifier = modifier.fillMaxSize(),
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()
}
}
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) }
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 = list.items.any { it.isChecked }
enabled = newItemName.isNotBlank()
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.remove_completed_icon_desc)
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
}
} 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 {
LazyColumn {
items(filteredItems) { item ->
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 {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
showMenu = true
}
)
},
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("Umbenennen") },
onClick = {
showMenu = false
showRenameDialog = true
}
)
DropdownMenuItem(
text = { Text("Menge ändern") },
onClick = {
showMenu = false
showQuantityDialog = true
}
)
}
val state = rememberReorderableLazyListState(onMove = { from, to ->
listId?.let { viewModel.moveItem(it, from.index, to.index) }
})
LazyColumn(
state = state.listState,
modifier = Modifier.reorderable(state)
) {
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(
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))
}
}
}
)
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
}
)
}
}
}
}
}
@Composable
fun RenameItemDialog(item: ShoppingListItem, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var text by remember { mutableStateOf(item.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Artikel umbenennen") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Neuer Name") }
)
},
confirmButton = {
Button(
onClick = { onRename(text) }
) {
Text("Speichern")
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}
@Composable
fun QuantityDialog(item: ShoppingListItem, onDismiss: () -> Unit, onSetQuantity: (String?) -> Unit) {
var text by remember { mutableStateOf(item.quantity ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Menge ändern") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
label = { Text("Menge (z.B. 1kg, 2 Stück)") }
)
},
confirmButton = {
Button(
onClick = { onSetQuantity(text.ifBlank { null }) }
) {
Text("Speichern")
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}
}
}
}
}
}
}
@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

@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine // New import
import kotlinx.coroutines.flow.filter // New import
import kotlinx.coroutines.flow.firstOrNull // New import
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -86,7 +86,8 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
id = item.id,
name = item.name,
isChecked = item.isChecked,
listId = item.listId
listId = item.listId,
displayOrder = item.displayOrder
)
}
@@ -96,7 +97,31 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
suspend fun saveShoppingListItem(item: ShoppingListItem) {
if (item.name.isNotBlank()) {
noteshopRepository.insertShoppingListItem(item)
var currentItem = item
if (currentItem.id == 0) {
val itemsCount = noteshopRepository.getItemsCount(currentItem.listId)
currentItem = currentItem.copy(displayOrder = itemsCount)
}
noteshopRepository.insertShoppingListItem(currentItem)
}
}
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)
}
}
}
}
@@ -135,8 +160,8 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
val updatedLists = list.mapIndexed { index, item ->
item.shoppingList.copy(displayOrder = index)
}
updatedLists.forEach {
noteshopRepository.updateShoppingList(it)
updatedLists.forEach { shoppingList ->
noteshopRepository.updateShoppingList(shoppingList)
}
}
}
@@ -170,13 +195,15 @@ data class ShoppingListItemDetails(
val id: Int = 0,
val name: String = "",
val isChecked: Boolean = false,
val listId: Int = 0
val listId: Int = 0,
val displayOrder: Int = 0
) {
fun toShoppingListItem(): ShoppingListItem = ShoppingListItem(
id = id,
name = name,
isChecked = isChecked,
listId = listId
listId = listId,
displayOrder = displayOrder
)
fun isValid(): Boolean {

View File

@@ -42,4 +42,9 @@
<string name="remove_completed_items">Remove completed</string>
<string name="no_uncompleted_items">No uncompleted items in this list.</string>
<string name="completed">completed</string>
<string name="rename">Rename</string>
<string name="change_quantity">Change Quantity</string>
<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>
</resources>