feat(shopping): Implement standard list loading and refined search UX

This commit is contained in:
2025-10-13 13:57:14 +02:00
parent 0dae398b4d
commit 9522b385c2
8 changed files with 222 additions and 18 deletions

View File

@@ -27,6 +27,8 @@ 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.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.PlaylistAddCheck
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.LooksOne
@@ -127,6 +129,7 @@ fun AppShell(
val showCompletedItems by shoppingListsViewModel.showCompletedItems.collectAsState()
val shoppingListWithItems by shoppingListsViewModel.getShoppingListWithItemsStream(selectedListId ?: 0)
.collectAsState(initial = null)
val isDetailSearchActive by shoppingListsViewModel.isDetailSearchActive.collectAsState()
val context = LocalContext.current
val exportLauncher = rememberLauncherForActivityResult(
@@ -179,6 +182,15 @@ fun AppShell(
}
)
}
NavigationDrawerItem(
label = { Text(stringResource(id = R.string.load_standard_list)) },
selected = false,
onClick = {
shoppingListsViewModel.createStandardList()
scope.launch { drawerState.close() }
}
)
}
}
) {
@@ -192,7 +204,7 @@ fun AppShell(
Column {
TopAppBar(
title = { Text(topBarTitle) },
title = { Text(if (currentScreen == Screen.ShoppingListDetail && isDetailSearchActive) stringResource(R.string.search) else topBarTitle) },
navigationIcon = {
if (isReorderMode) {
IconButton(onClick = { shoppingListsViewModel.disableReorderMode() }) {
@@ -202,7 +214,11 @@ fun AppShell(
when (currentScreen) {
is Screen.ShoppingListDetail -> {
IconButton(onClick = {
currentScreen = Screen.ShoppingLists; selectedListId = null
if (isDetailSearchActive) {
shoppingListsViewModel.toggleDetailSearch()
} else {
currentScreen = Screen.ShoppingLists; selectedListId = null
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
@@ -225,6 +241,9 @@ fun AppShell(
}
}
if (currentScreen == Screen.ShoppingListDetail) {
IconButton(onClick = { shoppingListsViewModel.toggleDetailSearch() }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
var showMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMenu = !showMenu }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
@@ -281,7 +300,7 @@ fun AppShell(
Icon(Icons.Filled.LooksOne, contentDescription = stringResource(R.string.add_item_simple_icon_desc))
}
IconButton(onClick = { shoppingListsViewModel.toggleShowCompletedItems() }) {
Icon(imageVector = if (showCompletedItems) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, contentDescription = "Toggle completed")
Icon(imageVector = if (showCompletedItems) Icons.AutoMirrored.Filled.List else Icons.AutoMirrored.Filled.PlaylistAddCheck, contentDescription = "Toggle completed")
}
IconButton(onClick = { scope.launch { selectedListId?.let { shoppingListsViewModel.deleteCompletedItems(it) } } }, enabled = shoppingListWithItems?.items?.any { it.isChecked } == true) {
Icon(Icons.Default.Delete, contentDescription = "Delete completed")

View File

@@ -34,7 +34,8 @@ class AppDataContainer(private val context: Application) : AppContainer {
override val noteshopRepository: NoteshopRepository by lazy {
OfflineNoteshopRepository(
noteDao = AppDatabase.getDatabase(context).noteDao(),
shoppingListDao = AppDatabase.getDatabase(context).shoppingListDao()
shoppingListDao = AppDatabase.getDatabase(context).shoppingListDao(),
context = context
)
}
}

View File

@@ -44,7 +44,7 @@ interface NoteshopRepository {
/**
* Insert shopping list in the data source
*/
suspend fun insertShoppingList(list: ShoppingList)
suspend fun insertShoppingList(list: ShoppingList): Long
/**
* Update shopping list in the data source
@@ -86,9 +86,14 @@ interface NoteshopRepository {
*/
suspend fun getItemsCount(listId: Int): Int
/**
* Get the standard list items from the assets.
*/
fun getStandardListItems(): List<String>
}
class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppingListDao: ShoppingListDao) : NoteshopRepository {
class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppingListDao: ShoppingListDao, private val context: android.content.Context) : NoteshopRepository {
override fun getAllNotesStream(): Flow<List<Note>> = noteDao.getAllNotes()
override fun getNoteStream(id: Int): Flow<Note?> = noteDao.getNote(id)
@@ -103,7 +108,7 @@ class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppi
override fun getShoppingListWithItemsStream(listId: Int): Flow<ShoppingListWithItems?> = shoppingListDao.getListWithItems(listId)
override suspend fun insertShoppingList(list: ShoppingList) = shoppingListDao.insertList(list)
override suspend fun insertShoppingList(list: ShoppingList): Long = shoppingListDao.insertList(list)
override suspend fun updateShoppingList(list: ShoppingList) = shoppingListDao.updateList(list)
@@ -121,4 +126,8 @@ class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppi
override suspend fun updateShoppingListItems(items: List<ShoppingListItem>) = shoppingListDao.updateItems(items)
override fun getStandardListItems(): List<String> {
return context.resources.getStringArray(de.lxtools.noteshop.R.array.standard_list_items).toList()
}
}

View File

@@ -14,7 +14,7 @@ interface ShoppingListDao {
// --- ShoppingList specific methods ---
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertList(list: ShoppingList)
suspend fun insertList(list: ShoppingList): Long
@Update
suspend fun updateList(list: ShoppingList)

View File

@@ -67,6 +67,7 @@ fun ShoppingListDetailScreen(
val showRenameDialog by viewModel.showRenameDialog.collectAsState()
val showQuantityDialog by viewModel.showQuantityDialog.collectAsState()
val isReorderMode by viewModel.isReorderMode.collectAsState()
val isDetailSearchActive by viewModel.isDetailSearchActive.collectAsState()
Column(
modifier = modifier
@@ -85,15 +86,21 @@ fun ShoppingListDetailScreen(
OutlinedTextField(
value = newItemName,
onValueChange = { viewModel.onNewItemNameChange(it) },
label = { Text(stringResource(R.string.add_item_hint)) },
label = { Text(if (isDetailSearchActive) stringResource(R.string.search) else stringResource(R.string.add_item_hint)) },
modifier = Modifier.fillMaxWidth()
)
val currentSearchTerm = if (isDetailSearchActive) {
newItemName.substringAfterLast(',').trim()
} else {
""
}
if (newItemName.isNotBlank()) {
val currentSearchTerm = newItemName.substringAfterLast(',').trim()
val suggestions = if (currentSearchTerm.isNotBlank()) {
val suggestionSearchTerm = newItemName.substringAfterLast(',').trim()
val suggestions = if (suggestionSearchTerm.isNotBlank()) {
shoppingListWithItems?.items?.filter {
it.name.contains(currentSearchTerm, ignoreCase = true)
it.name.contains(suggestionSearchTerm, ignoreCase = true)
} ?: emptyList()
} else {
emptyList()
@@ -148,20 +155,24 @@ fun ShoppingListDetailScreen(
Text(text = stringResource(R.string.no_items_in_list))
} else {
val allItems = list.items.sortedBy { it.displayOrder }
val filteredItems = if (showCompletedItems) {
allItems
var itemsToDisplay = if (showCompletedItems) {
allItems.filter { it.isChecked }
} else {
allItems.filter { !it.isChecked }
}
if (filteredItems.isEmpty() && !showCompletedItems) {
if (isDetailSearchActive && currentSearchTerm.isNotBlank()) {
itemsToDisplay = itemsToDisplay.filter { it.name.contains(currentSearchTerm, ignoreCase = true) }
}
if (itemsToDisplay.isEmpty() && !showCompletedItems) {
Text(text = stringResource(R.string.no_uncompleted_items))
} else if (filteredItems.isEmpty() && showCompletedItems) {
} else if (itemsToDisplay.isEmpty() && showCompletedItems) {
Text(text = stringResource(R.string.no_items_in_list))
} else {
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
val reorderedItems = filteredItems.toMutableList().apply {
val reorderedItems = itemsToDisplay.toMutableList().apply {
add(to.index, removeAt(from.index))
}
viewModel.moveItem(reorderedItems)
@@ -169,7 +180,7 @@ fun ShoppingListDetailScreen(
LazyColumn(
state = lazyListState,
) {
items(filteredItems, { it.id }) { item ->
items(itemsToDisplay, { it.id }) { item ->
ReorderableItem(reorderableLazyListState, key = item.id) { isDragging ->
val scale = animateFloatAsState(if (isDragging) 1.05f else 1.0f, label = "scale")

View File

@@ -40,6 +40,9 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
private val _isReorderMode = MutableStateFlow(false)
val isReorderMode: StateFlow<Boolean> = _isReorderMode.asStateFlow()
private val _isDetailSearchActive = MutableStateFlow(false)
val isDetailSearchActive: StateFlow<Boolean> = _isDetailSearchActive.asStateFlow()
fun onNewItemNameChange(name: String) {
_newItemName.value = name
}
@@ -64,6 +67,10 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
_isReorderMode.value = false
}
fun toggleDetailSearch() {
_isDetailSearchActive.value = !_isDetailSearchActive.value
}
val uiState: StateFlow<ShoppingListsUiState> = combine(
noteshopRepository.getAllShoppingListsWithItemsStream(),
searchQuery
@@ -283,6 +290,33 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
}
}
fun createStandardList() {
viewModelScope.launch {
val allLists = uiState.value.shoppingLists
val standardListName = "Standard"
var newListName = standardListName
var counter = 1
while (allLists.any { it.shoppingList.name == newListName }) {
newListName = "$standardListName ($counter)"
counter++
}
val listsCount = noteshopRepository.getListsCount()
val newList = ShoppingList(name = newListName, displayOrder = listsCount)
val newListId = noteshopRepository.insertShoppingList(newList)
val standardItems = noteshopRepository.getStandardListItems()
standardItems.forEachIndexed { index, itemName ->
val newItem = ShoppingListItem(
name = itemName,
listId = newListId.toInt(),
displayOrder = index
)
noteshopRepository.insertShoppingListItem(newItem)
}
}
}
fun formatShoppingListForExport(listWithItems: ShoppingListWithItems): String {
val builder = StringBuilder()
builder.appendLine(listWithItems.shoppingList.name)

View File

@@ -56,4 +56,69 @@
<string name="rename_item">Element umbenennen</string>
<string name="set_quantity">Menge festlegen</string>
<string name="already_in_list">ist schon in der liste</string>
<string name="load_standard_list">Standardliste laden</string>
<string name="search">Suche</string>
<string-array name="standard_list_items">
<item>Brötchen</item>
<item>Red Bull</item>
<item>Kartoffeln</item>
<item>Reis</item>
<item>Karotten</item>
<item>Brokkoli</item>
<item>Schinken</item>
<item>Bolognese Fix</item>
<item>Sprudel</item>
<item>Schweinebraten</item>
<item>Kiwi</item>
<item>Bananen</item>
<item>Hamburger Fleisch</item>
<item>Asia Nudeln</item>geo
<item>Nudeln</item>
<item>Wienerle</item>
<item>Äpfel</item>
<item>Kirschen</item>
<item>Bockwurst</item>
<item>Erbsen Wurzel Gemüse</item>
<item>Milchreis</item>
<item>Rotkohl</item>
<item>Antikalk</item>
<item>Apfelmus</item>
<item>Käse</item>
<item>Reibekäse</item>
<item>Kaffee</item>
<item>Salz</item>
<item>Kräutersalz</item>
<item>Pfeffer</item>
<item>Pommes</item>
<item>Pommes Salz</item>
<item>Mehl</item>
<item>Haferflocken</item>
<item>Wattestäbchen</item>
<item>Eis</item>
<item>Nuggets</item>
<item>Batterien</item>
<item>Lasangne Platten</item>
<item>Pizza Baguette</item>
<item>Backofen Fisch</item>
<item>Schicken Wings</item>
<item>Wurst</item>
<item>Gyros</item>
<item>Zatziki</item>geo
<item>Eier</item>
<item>Margarine</item>
<item>Butter</item>
<item>Honig</item>
<item>Eisberg Salat</item>
<item>Frituesen Fett</item>
<item>Rapsöl</item>
<item>Olivenöl</item>
<item>Wurstsalat</item>
<item>Lorbeer Blätter</item>
<item>Küchen Tücher</item>
<item>Desinfektionsmittel</item>
<item>Putzmittel</item>
<item>Kaba</item>
<item>Zitronentee</item>
<item>Kidney Bohnen</item>
</string-array>
</resources>

View File

@@ -56,4 +56,69 @@
<string name="rename_item">Rename item</string>
<string name="set_quantity">Set quantity</string>
<string name="already_in_list">already in list</string>
<string name="load_standard_list">Load standard list</string>
<string name="search">Search</string>
<string-array name="standard_list_items">
<item>Bread rolls</item>
<item>Red Bull</item>
<item>Potatoes</item>
<item>Rice</item>
<item>Carrots</item>
<item>Broccoli</item>
<item>Ham</item>
<item>Bolognese mix</item>
<item>Sparkling water</item>
<item>Roast pork</item>
<item>Kiwi</item>
<item>Bananas</item>
<item>Hamburger meat</item>
<item>Asian noodles</item>
<item>Pasta</item>
<item>Viennese sausages</item>
<item>Apples</item>
<item>Cherries</item>
<item>Bockwurst</item>
<item>Peas root vegetables</item>
<item>Rice pudding</item>
<item>Red cabbage</item>
<item>Anti-limescale</item>
<item>Apple sauce</item>
<item>Cheese</item>
<item>Grated cheese</item>
<item>Coffee</item>
<item>Salt</item>
<item>Herbal salt</item>
<item>Pepper</item>
<item>French fries</item>
<item>French fry salt</item>
<item>Flour</item>
<item>Oatmeal</item>
<item>Cotton swabs</item>
<item>Ice cream</item>
<item>Nuggets</item>
<item>Batteries</item>
<item>Lasagna sheets</item>
<item>Pizza baguette</item>
<item>Baked fish</item>
<item>Chicken wings</item>
<item>Sausage</item>
<item>Gyros</item>
<item>Tzatziki</item>
<item>Eggs</item>
<item>Margarine</item>
<item>Butter</item>
<item>Honey</item>
<item>Iceberg lettuce</item>
<item>Deep fryer fat</item>
<item>Canola oil</item>
<item>Olive oil</item>
<item>Sausage salad</item>
<item>Bay leaves</item>
<item>Kitchen towels</item>
<item>Disinfectant</item>
<item>Cleaning supplies</item>
<item>Cocoa powder</item>
<item>Lemon tea</item>
<item>Kidney beans</item>
</string-array>
</resources>