Feat: Shopping List views enhanced with search, item management, and UI improvements.

This commit is contained in:
2025-10-11 01:54:18 +02:00
parent a2c9932d90
commit 781c3e50f2
5 changed files with 189 additions and 100 deletions

View File

@@ -1,5 +1,7 @@
package de.lxtools.noteshop.ui.shopping
import androidx.compose.foundation.layout.Arrangement // New import
import androidx.compose.foundation.layout.Box // New import
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -7,15 +9,21 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.material3.Button // New import
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField // New import
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue // New import
import androidx.compose.runtime.mutableStateOf // New import
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue // New import
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -34,61 +42,129 @@ fun ShoppingListDetailScreen(
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
var newItemName by remember { mutableStateOf("") }
val showCompletedItems by viewModel.showCompletedItems.collectAsState() // New: collect showCompletedItems state
// Fetch the specific shopping list with items
val shoppingListWithItems by viewModel.getShoppingListWithItemsStream(listId ?: 0).collectAsState(initial = null)
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
if (shoppingListWithItems == null) { // Changed from ?: run { ... }
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))
// Optionally, a button to go back
// Button(onClick = onBack) {
// Text(stringResource(R.string.back))
// }
} else {
val list = shoppingListWithItems!! // Safe to use !! here because we checked for null
Text(
text = list.shoppingList.name,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
if (list.items.isEmpty()) {
Text(text = stringResource(R.string.no_items_in_list))
Box(modifier = modifier.fillMaxSize()) { // Use Box for button placement
Column(
modifier = Modifier
.fillMaxSize()
.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 {
LazyColumn {
items(list.items) { item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isChecked,
onCheckedChange = { isChecked ->
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = isChecked))
}
val list = shoppingListWithItems!!
Text(
text = list.shoppingList.name,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = newItemName,
onValueChange = { newItemName = it },
label = { Text(stringResource(R.string.add_item_hint)) },
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (newItemName.isNotBlank()) {
coroutineScope.launch {
viewModel.saveShoppingListItem(
ShoppingListItem(name = newItemName, listId = list.shoppingList.id)
)
newItemName = "" // Clear input field
}
)
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
},
enabled = newItemName.isNotBlank()
) {
Text(stringResource(R.string.add_item)) // Reusing string, should be "Add"
}
}
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
}
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 ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isChecked,
onCheckedChange = { isChecked ->
coroutineScope.launch {
viewModel.saveShoppingListItem(item.copy(isChecked = isChecked))
}
}
)
Text(
text = item.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
}
// New: Buttons at bottom right - direct child of Box
if (shoppingListWithItems != null) { // Only show buttons if list exists
val list = shoppingListWithItems!! // Re-declare list for scope
Column(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = { viewModel.toggleShowCompletedItems() }) {
Text(text = if (showCompletedItems) stringResource(R.string.hide_completed_items) else stringResource(R.string.show_completed_items))
}
Button(
onClick = {
coroutineScope.launch {
listId?.let { viewModel.deleteCompletedItems(it) }
}
},
enabled = list.items.any { it.isChecked } // Enable only if there are checked items
) {
Text(stringResource(R.string.remove_completed_items))
}
}
}
}
}

View File

@@ -13,6 +13,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -20,6 +23,7 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
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
@@ -54,6 +58,7 @@ fun ShoppingListsScreen(
val uiState by viewModel.uiState.collectAsState()
val listDetails by viewModel.listDetails.collectAsState()
val itemDetails by viewModel.itemDetails.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState() // New: collect search query
var showListDialog by remember { mutableStateOf(false) }
var showItemDialog by remember { mutableStateOf(false) }
@@ -77,6 +82,13 @@ fun ShoppingListsScreen(
.padding(innerPadding)
.padding(16.dp)
) {
OutlinedTextField( // New: Search input field
value = searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) },
label = { Text(stringResource(R.string.search_list_hint)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)
)
if (uiState.shoppingLists.isEmpty()) {
Text(text = stringResource(R.string.no_shopping_lists_yet))
} else {
@@ -93,25 +105,6 @@ fun ShoppingListsScreen(
viewModel.deleteList(it.shoppingList)
}
},
onAddItem = {
currentSelectedListId = it.shoppingList.id
viewModel.resetItemDetails(it.shoppingList.id)
showItemDialog = true
},
onEditItem = {
viewModel.updateItemDetails(it)
showItemDialog = true
},
onToggleItemChecked = {
coroutineScope.launch {
viewModel.saveShoppingListItem(it.copy(isChecked = !it.isChecked))
}
},
onDeleteItem = {
coroutineScope.launch {
viewModel.deleteItem(it)
}
},
modifier = Modifier.clickable { onListClick(listWithItems.shoppingList.id) } // Make card clickable
)
}
@@ -158,10 +151,6 @@ fun ShoppingListCard(
listWithItems: ShoppingListWithItems,
onEditList: (ShoppingListWithItems) -> Unit,
onDeleteList: (ShoppingListWithItems) -> Unit,
onAddItem: (ShoppingListWithItems) -> Unit,
onEditItem: (ShoppingListItem) -> Unit,
onToggleItemChecked: (ShoppingListItem) -> Unit,
onDeleteItem: (ShoppingListItem) -> Unit,
modifier: Modifier = Modifier
) {
Card(
@@ -180,39 +169,13 @@ fun ShoppingListCard(
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
TextButton(onClick = { onEditList(listWithItems) }) {
Text(stringResource(R.string.edit))
IconButton(onClick = { onEditList(listWithItems) }) {
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit_list))
}
TextButton(onClick = { onDeleteList(listWithItems) }) {
Text(stringResource(R.string.delete))
IconButton(onClick = { onDeleteList(listWithItems) }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete_list))
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = stringResource(R.string.items_in_list))
listWithItems.items.forEach { item ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Checkbox(
checked = item.isChecked,
onCheckedChange = { onToggleItemChecked(item) }
)
Text(
text = item.name,
modifier = Modifier
.weight(1f)
.clickable { onEditItem(item) }
)
TextButton(onClick = { onDeleteItem(item) }) {
Text(stringResource(R.string.delete))
}
}
}
Button(onClick = { onAddItem(listWithItems) }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.add_item))
}
}
}
}

View File

@@ -11,13 +11,31 @@ import kotlinx.coroutines.flow.MutableStateFlow
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.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
val uiState: StateFlow<ShoppingListsUiState> = noteshopRepository.getAllShoppingListsWithItemsStream().map { ShoppingListsUiState(it) }
private val _searchQuery = MutableStateFlow("") // New search query state
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _showCompletedItems = MutableStateFlow(false) // New state for showing completed items
val showCompletedItems: StateFlow<Boolean> = _showCompletedItems.asStateFlow()
val uiState: StateFlow<ShoppingListsUiState> = combine(
noteshopRepository.getAllShoppingListsWithItemsStream(),
searchQuery
) { allLists, query ->
val filteredLists = if (query.isBlank()) {
allLists
} else {
allLists.filter { it.shoppingList.name.contains(query, ignoreCase = true) }
}
ShoppingListsUiState(filteredLists)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS),
@@ -91,6 +109,22 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
return noteshopRepository.getShoppingListWithItemsStream(listId)
}
fun updateSearchQuery(query: String) { // New function to update search query
_searchQuery.value = query
}
fun toggleShowCompletedItems() { // New function to toggle show completed items
_showCompletedItems.value = !_showCompletedItems.value
}
suspend fun deleteCompletedItems(listId: Int) { // New function to delete completed items
noteshopRepository.getShoppingListWithItemsStream(listId).collect { listWithItems ->
listWithItems?.items?.filter { it.isChecked }?.forEach { item ->
noteshopRepository.deleteShoppingListItem(item)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}

View File

@@ -24,7 +24,15 @@
<string name="item_name">Artikelname</string>
<string name="item_checked">Abgehakt</string>
<string name="edit">Bearbeiten</string>
<string name="edit_list">Liste bearbeiten</string>
<string name="delete_list">Liste löschen</string>
<string name="menu_shopping_list_detail">Einkaufslisten-Details</string>
<string name="no_items_in_list">Noch keine Artikel in dieser Liste.</string>
<string name="shopping_list_not_found">Einkaufsliste nicht gefunden.</string>
<string name="search_list_hint">Listen suchen...</string>
<string name="add_item_hint">Artikelname</string>
<string name="show_completed_items">Erledigte anzeigen</string>
<string name="hide_completed_items">Erledigte ausblenden</string>
<string name="remove_completed_items">Erledigte entfernen</string>
<string name="no_uncompleted_items">Keine unerledigten Artikel in dieser Liste.</string>
</resources>

View File

@@ -24,7 +24,15 @@
<string name="item_name">Item Name</string>
<string name="item_checked">Checked</string>
<string name="edit">Edit</string>
<string name="edit_list">Edit list</string>
<string name="delete_list">Delete list</string>
<string name="menu_shopping_list_detail">Shopping List Details</string>
<string name="no_items_in_list">No items in this list yet.</string>
<string name="shopping_list_not_found">Shopping list not found.</string>
<string name="search_list_hint">Search lists...</string>
<string name="add_item_hint">Item name</string>
<string name="show_completed_items">Show completed</string>
<string name="hide_completed_items">Hide completed</string>
<string name="remove_completed_items">Remove completed</string>
<string name="no_uncompleted_items">No uncompleted items in this list.</string>
</resources>