diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt index 228806e..d48058d 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListScreen.kt @@ -1,6 +1,7 @@ package de.krisenvorrat.app.ui.item import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,22 +11,29 @@ 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.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip 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 import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -106,21 +114,44 @@ internal fun ItemListScreen( } } ) { innerPadding -> - if (uiState.isEmpty) { - EmptyState( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + ItemSearchBar( + query = uiState.searchQuery, + onQueryChanged = viewModel::onSearchQueryChanged, + onClearClick = viewModel::clearSearch ) - } else { - ItemList( - groupedItems = uiState.groupedItems, - onItemClick = onItemClick, - onDeleteClick = viewModel::showDeleteDialog, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) + + FilterChipRow( + uiState = uiState, + onCategorySelected = viewModel::onCategoryFilterChanged, + onLocationSelected = viewModel::onLocationFilterChanged, + onExpirySelected = viewModel::onExpiryFilterChanged ) + + when { + uiState.isEmpty -> EmptyState( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + uiState.hasNoFilterResults -> NoFilterResultsState( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + else -> ItemList( + groupedItems = uiState.groupedItems, + onItemClick = onItemClick, + onDeleteClick = viewModel::showDeleteDialog, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + } } } @@ -256,6 +287,145 @@ private fun ExpiryDateText(item: ItemUiModel) { ) } +@Composable +private fun ItemSearchBar( + query: String, + onQueryChanged: (String) -> Unit, + onClearClick: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChanged, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Suchen...") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = "Suchen") + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClearClick) { + Icon(Icons.Default.Close, contentDescription = "Suche löschen") + } + } + }, + singleLine = true + ) +} + +@Composable +private fun FilterChipRow( + uiState: ItemListUiState, + onCategorySelected: (Int?) -> Unit, + onLocationSelected: (Int?) -> Unit, + onExpirySelected: (ExpiryFilter?) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterDropdownChip( + label = "Kategorie", + selectedLabel = uiState.availableCategories + .find { it.first == uiState.selectedCategoryId }?.second, + options = uiState.availableCategories, + onOptionSelected = { onCategorySelected(it) }, + onClear = { onCategorySelected(null) } + ) + + FilterDropdownChip( + label = "Lagerort", + selectedLabel = uiState.availableLocations + .find { it.first == uiState.selectedLocationId }?.second, + options = uiState.availableLocations, + onOptionSelected = { onLocationSelected(it) }, + onClear = { onLocationSelected(null) } + ) + + FilterDropdownChip( + label = "Ablauf", + selectedLabel = uiState.selectedExpiryFilter?.label, + options = ExpiryFilter.entries.map { it.ordinal to it.label }, + onOptionSelected = { ordinal -> + onExpirySelected(ExpiryFilter.entries[ordinal]) + }, + onClear = { onExpirySelected(null) } + ) + } +} + +@Composable +private fun FilterDropdownChip( + label: String, + selectedLabel: String?, + options: List>, + onOptionSelected: (Int) -> Unit, + onClear: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val isSelected = selectedLabel != null + + Box { + FilterChip( + selected = isSelected, + onClick = { expanded = true }, + label = { + Text(if (isSelected) "$label: $selectedLabel" else label) + }, + trailingIcon = { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + if (isSelected) { + DropdownMenuItem( + text = { Text("Alle") }, + onClick = { + expanded = false + onClear() + } + ) + } + options.forEach { (id, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + expanded = false + onOptionSelected(id) + } + ) + } + } + } +} + +@Composable +private fun NoFilterResultsState(modifier: Modifier = Modifier) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = "Keine Items gefunden", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + @Composable private fun DeleteItemDialog( itemName: String, diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt index 71661db..9f6323a 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemListViewModel.kt @@ -15,12 +15,39 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +internal enum class ExpiryFilter(val label: String) { + EXPIRED("Abgelaufen"), + EXPIRING_SOON("Bald ablaufend"), + OK("OK") +} + +private data class FilterState( + val searchQuery: String = "", + val categoryId: Int? = null, + val locationId: Int? = null, + val expiryFilter: ExpiryFilter? = null +) + internal data class ItemListUiState( val groupedItems: Map> = emptyMap(), + val totalItemCount: Int = 0, + val searchQuery: String = "", + val selectedCategoryId: Int? = null, + val selectedLocationId: Int? = null, + val selectedExpiryFilter: ExpiryFilter? = null, + val availableCategories: List> = emptyList(), + val availableLocations: List> = emptyList(), val isDeleteDialogVisible: Boolean = false, val itemToDelete: ItemUiModel? = null ) { - val isEmpty: Boolean get() = groupedItems.isEmpty() + val isEmpty: Boolean get() = totalItemCount == 0 + val isFiltering: Boolean + get() = searchQuery.isNotBlank() || + selectedCategoryId != null || + selectedLocationId != null || + selectedExpiryFilter != null + val hasNoFilterResults: Boolean + get() = isFiltering && groupedItems.isEmpty() && !isEmpty } @HiltViewModel @@ -30,6 +57,7 @@ internal class ItemListViewModel @Inject constructor( private val locationRepository: LocationRepository ) : ViewModel() { + private val _filterState = MutableStateFlow(FilterState()) private val _uiState = MutableStateFlow(ItemListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -38,12 +66,13 @@ internal class ItemListViewModel @Inject constructor( combine( itemRepository.getAll(), categoryRepository.getAll(), - locationRepository.getAll() - ) { items, categories, locations -> + locationRepository.getAll(), + _filterState + ) { items, categories, locations, filters -> val categoryMap = categories.associate { it.id to it.name } val locationMap = locations.associate { it.id to it.name } - items.map { item -> + val allUiItems = items.map { item -> ItemUiModel( id = item.id, name = item.name, @@ -52,16 +81,83 @@ internal class ItemListViewModel @Inject constructor( unit = item.unit, locationName = locationMap[item.locationId] ?: "Unbekannt", expiryDate = item.expiryDate, - categoryId = item.categoryId + categoryId = item.categoryId, + locationId = item.locationId, + notes = item.notes ) - }.groupBy { it.categoryName } + } + + val filteredItems = allUiItems + .filter { item -> + if (filters.searchQuery.isBlank()) true + else { + val query = filters.searchQuery.lowercase() + item.name.lowercase().contains(query) || + item.notes.lowercase().contains(query) + } + } + .filter { item -> + filters.categoryId == null || item.categoryId == filters.categoryId + } + .filter { item -> + filters.locationId == null || item.locationId == filters.locationId + } + .filter { item -> + when (filters.expiryFilter) { + null -> true + ExpiryFilter.EXPIRED -> item.isExpired + ExpiryFilter.EXPIRING_SOON -> item.isExpiringSoon + ExpiryFilter.OK -> !item.isExpired && !item.isExpiringSoon + } + } + + val grouped = filteredItems + .groupBy { it.categoryName } .toSortedMap() - }.collect { grouped -> - _uiState.update { it.copy(groupedItems = grouped) } + + ItemListUiState( + groupedItems = grouped, + totalItemCount = allUiItems.size, + searchQuery = filters.searchQuery, + selectedCategoryId = filters.categoryId, + selectedLocationId = filters.locationId, + selectedExpiryFilter = filters.expiryFilter, + availableCategories = categories.map { it.id to it.name } + .sortedBy { it.second }, + availableLocations = locations.map { it.id to it.name } + .sortedBy { it.second } + ) + }.collect { newState -> + _uiState.update { old -> + newState.copy( + isDeleteDialogVisible = old.isDeleteDialogVisible, + itemToDelete = old.itemToDelete + ) + } } } } + fun onSearchQueryChanged(query: String) { + _filterState.update { it.copy(searchQuery = query) } + } + + fun onCategoryFilterChanged(categoryId: Int?) { + _filterState.update { it.copy(categoryId = categoryId) } + } + + fun onLocationFilterChanged(locationId: Int?) { + _filterState.update { it.copy(locationId = locationId) } + } + + fun onExpiryFilterChanged(expiryFilter: ExpiryFilter?) { + _filterState.update { it.copy(expiryFilter = expiryFilter) } + } + + fun clearSearch() { + _filterState.update { it.copy(searchQuery = "") } + } + fun showDeleteDialog(item: ItemUiModel) { _uiState.update { it.copy(isDeleteDialogVisible = true, itemToDelete = item) } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt index 51c0d20..80b4625 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/item/ItemUiModel.kt @@ -10,7 +10,9 @@ internal data class ItemUiModel( val unit: String, val locationName: String, val expiryDate: LocalDate?, - val categoryId: Int + val categoryId: Int, + val locationId: Int = 0, + val notes: String = "" ) { val isExpired: Boolean diff --git a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt index 6ce6ae2..b4f3ffc 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/item/ItemListViewModelTest.kt @@ -214,6 +214,336 @@ class ItemListViewModelTest { val keys = viewModel.uiState.value.groupedItems.keys.toList() assertEquals(listOf("Arzneimittel", "Lebensmittel", "Wasser"), keys) } + + // region Search & Filter Tests + + @Test + fun test_searchByName_filtersCorrectly() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis"), + buildItemEntity(id = "b", name = "Konserve"), + buildItemEntity(id = "c", name = "Reismehl") + ) + ) + advanceUntilIdle() + + // When + viewModel.onSearchQueryChanged("Reis") + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(2, allItems.size) + assertTrue(allItems.all { it.name.contains("Reis") }) + } + + @Test + fun test_searchByNotes_filtersCorrectly() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis", notes = "glutenfrei"), + buildItemEntity(id = "b", name = "Nudeln", notes = "enthält Gluten") + ) + ) + advanceUntilIdle() + + // When + viewModel.onSearchQueryChanged("glutenfrei") + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Reis", allItems.first().name) + } + + @Test + fun test_searchIsCaseInsensitive() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis"), + buildItemEntity(id = "b", name = "Nudeln") + ) + ) + advanceUntilIdle() + + // When + viewModel.onSearchQueryChanged("reis") + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Reis", allItems.first().name) + } + + @Test + fun test_filterByCategory_showsOnlyMatchingItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit( + listOf( + CategoryEntity(id = 1, name = "Lebensmittel"), + CategoryEntity(id = 2, name = "Hygiene") + ) + ) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis", categoryId = 1), + buildItemEntity(id = "b", name = "Seife", categoryId = 2) + ) + ) + advanceUntilIdle() + + // When + viewModel.onCategoryFilterChanged(1) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Reis", allItems.first().name) + } + + @Test + fun test_filterByLocation_showsOnlyMatchingItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit( + listOf( + LocationEntity(id = 1, name = "Keller"), + LocationEntity(id = 2, name = "Speisekammer") + ) + ) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis", locationId = 1), + buildItemEntity(id = "b", name = "Nudeln", locationId = 2) + ) + ) + advanceUntilIdle() + + // When + viewModel.onLocationFilterChanged(2) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Nudeln", allItems.first().name) + } + + @Test + fun test_filterByExpiryExpired_showsOnlyExpiredItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)), + buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90)) + ) + ) + advanceUntilIdle() + + // When + viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRED) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Abgelaufen", allItems.first().name) + } + + @Test + fun test_filterByExpirySoon_showsOnlyExpiringSoonItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)), + buildItemEntity(id = "b", name = "BaldAblaufend", expiryDate = LocalDate.now().plusDays(10)), + buildItemEntity(id = "c", name = "Frisch", expiryDate = LocalDate.now().plusDays(90)) + ) + ) + advanceUntilIdle() + + // When + viewModel.onExpiryFilterChanged(ExpiryFilter.EXPIRING_SOON) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("BaldAblaufend", allItems.first().name) + } + + @Test + fun test_filterByExpiryOk_showsOnlyOkItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Abgelaufen", expiryDate = LocalDate.now().minusDays(5)), + buildItemEntity(id = "b", name = "Frisch", expiryDate = LocalDate.now().plusDays(90)), + buildItemEntity(id = "c", name = "OhneDatum", expiryDate = null) + ) + ) + advanceUntilIdle() + + // When + viewModel.onExpiryFilterChanged(ExpiryFilter.OK) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(2, allItems.size) + assertTrue(allItems.map { it.name }.containsAll(listOf("Frisch", "OhneDatum"))) + } + + @Test + fun test_combinedSearchAndFilter_appliesBoth() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit( + listOf( + CategoryEntity(id = 1, name = "Lebensmittel"), + CategoryEntity(id = 2, name = "Hygiene") + ) + ) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis", categoryId = 1), + buildItemEntity(id = "b", name = "Reisseife", categoryId = 2), + buildItemEntity(id = "c", name = "Nudeln", categoryId = 1) + ) + ) + advanceUntilIdle() + + // When + viewModel.onSearchQueryChanged("Reis") + viewModel.onCategoryFilterChanged(1) + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(1, allItems.size) + assertEquals("Reis", allItems.first().name) + } + + @Test + fun test_clearSearch_showsAllItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis"), + buildItemEntity(id = "b", name = "Nudeln") + ) + ) + advanceUntilIdle() + viewModel.onSearchQueryChanged("Reis") + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size) + + // When + viewModel.clearSearch() + advanceUntilIdle() + + // Then + val allItems = viewModel.uiState.value.groupedItems.values.flatten() + assertEquals(2, allItems.size) + } + + @Test + fun test_noFilterResults_hasNoFilterResultsIsTrue() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Lebensmittel"))) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit(listOf(buildItemEntity(id = "a", name = "Reis"))) + advanceUntilIdle() + + // When + viewModel.onSearchQueryChanged("XYZ-nicht-vorhanden") + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertTrue(state.hasNoFilterResults) + assertFalse(state.isEmpty) + assertTrue(state.groupedItems.isEmpty()) + } + + @Test + fun test_availableCategoriesAndLocations_arePopulated() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit( + listOf( + CategoryEntity(id = 2, name = "Hygiene"), + CategoryEntity(id = 1, name = "Lebensmittel") + ) + ) + fakeLocationRepository.emit( + listOf( + LocationEntity(id = 2, name = "Speisekammer"), + LocationEntity(id = 1, name = "Keller") + ) + ) + fakeItemRepository.emit(emptyList()) + + // When + advanceUntilIdle() + + // Then + val state = viewModel.uiState.value + assertEquals(listOf(2 to "Hygiene", 1 to "Lebensmittel"), state.availableCategories) + assertEquals(listOf(1 to "Keller", 2 to "Speisekammer"), state.availableLocations) + } + + @Test + fun test_filterByCategory_thenClear_showsAllItems() = runTest(testDispatcher) { + // Given + fakeCategoryRepository.emit( + listOf( + CategoryEntity(id = 1, name = "Lebensmittel"), + CategoryEntity(id = 2, name = "Hygiene") + ) + ) + fakeLocationRepository.emit(listOf(LocationEntity(id = 1, name = "Keller"))) + fakeItemRepository.emit( + listOf( + buildItemEntity(id = "a", name = "Reis", categoryId = 1), + buildItemEntity(id = "b", name = "Seife", categoryId = 2) + ) + ) + advanceUntilIdle() + viewModel.onCategoryFilterChanged(1) + advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.groupedItems.values.flatten().size) + + // When + viewModel.onCategoryFilterChanged(null) + advanceUntilIdle() + + // Then + assertEquals(2, viewModel.uiState.value.groupedItems.values.flatten().size) + } + + // endregion } // region Test Helpers @@ -222,7 +552,9 @@ private fun buildItemEntity( id: String = "id1", name: String = "Konserve", categoryId: Int = 1, - locationId: Int = 1 + locationId: Int = 1, + notes: String = "", + expiryDate: LocalDate? = null ) = ItemEntity( id = id, name = name, @@ -231,9 +563,9 @@ private fun buildItemEntity( unit = "Stk", unitPrice = 1.5, kcalPerKg = null, - expiryDate = null, + expiryDate = expiryDate, locationId = locationId, - notes = "", + notes = notes, lastUpdated = 0L )