feat(#106): category tap on dashboard navigates to inventory with filter

- Change Screen.ItemList from data object to data class with optional categoryId
- DashboardScreen: make CategoryCard clickable with onCategoryClick callback
- ItemListViewModel: read initial categoryId from SavedStateHandle
- BollwerkNavGraph: wire category click to navigate with categoryId
- Add test for initial category filter from navigation args
This commit is contained in:
Jens Reinemann 2026-05-18 10:01:14 +02:00
parent 7ccd2dc1fd
commit bdd8cb4b11
7 changed files with 78 additions and 8 deletions

View file

@ -34,6 +34,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DashboardScreen(
onCategoryClick: (Int) -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -77,7 +78,10 @@ internal fun DashboardScreen(
)
}
items(uiState.categorySummaries) { summary ->
CategoryCard(summary)
CategoryCard(
summary = summary,
onClick = { onCategoryClick(summary.categoryId) }
)
}
}
@ -201,11 +205,13 @@ private fun WarningsSummaryCard(expiryCount: Int) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CategoryCard(summary: CategorySummary) {
private fun CategoryCard(summary: CategorySummary, onClick: () -> Unit) {
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.ui.item
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -52,12 +53,14 @@ internal data class ItemListUiState(
@HiltViewModel
internal class ItemListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val itemRepository: ItemRepository,
private val categoryRepository: CategoryRepository,
private val locationRepository: LocationRepository
) : ViewModel() {
private val _filterState = MutableStateFlow(FilterState())
private val initialCategoryId: Int? = savedStateHandle.get<Int>("categoryId")
private val _filterState = MutableStateFlow(FilterState(categoryId = initialCategoryId))
private val _uiState = MutableStateFlow(ItemListUiState())
val uiState: StateFlow<ItemListUiState> = _uiState.asStateFlow()

View file

@ -27,7 +27,11 @@ internal fun BollwerkNavGraph(
modifier = modifier
) {
composable<Screen.Dashboard> {
DashboardScreen()
DashboardScreen(
onCategoryClick = { categoryId ->
navController.navigate(Screen.ItemList(categoryId = categoryId))
}
)
}
composable<Screen.ItemList> {

View file

@ -9,7 +9,7 @@ internal sealed interface Screen {
data object Dashboard : Screen
@Serializable
data object ItemList : Screen
data class ItemList(val categoryId: Int? = null) : Screen
@Serializable
data class ItemForm(val itemId: String? = null, val prefillJson: String? = null) : Screen

View file

@ -26,7 +26,7 @@ internal enum class TopLevelDestination(
label = "Overview"
),
INVENTORY(
route = Screen.ItemList,
route = Screen.ItemList(),
selectedIcon = Icons.Filled.Warehouse,
unselectedIcon = Icons.Outlined.Warehouse,
label = "Storage"

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.ui.item
import androidx.lifecycle.SavedStateHandle
import de.bollwerk.app.data.db.entity.CategoryEntity
import de.bollwerk.app.data.db.entity.ItemEntity
import de.bollwerk.app.data.db.entity.LocationEntity
@ -40,6 +41,7 @@ class ItemListViewModelTest {
fakeCategoryRepository = FakeCategoryRepository()
fakeLocationRepository = FakeLocationRepository()
viewModel = ItemListViewModel(
savedStateHandle = SavedStateHandle(),
itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository,
locationRepository = fakeLocationRepository
@ -543,6 +545,40 @@ class ItemListViewModelTest {
assertEquals(2, viewModel.uiState.value.groupedItems.values.flatten().size)
}
@Test
fun test_init_withCategoryIdFromNavigation_filterIsPreApplied() = runTest(testDispatcher) {
// Given
val vmWithCategory = ItemListViewModel(
savedStateHandle = SavedStateHandle(mapOf("categoryId" to 2)),
itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository,
locationRepository = fakeLocationRepository
)
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)
)
)
// When
advanceUntilIdle()
// Then
val state = vmWithCategory.uiState.value
assertEquals(2, state.selectedCategoryId)
val allItems = state.groupedItems.values.flatten()
assertEquals(1, allItems.size)
assertEquals("Seife", allItems.first().name)
}
// endregion
}

View file

@ -9,10 +9,10 @@ class ScreenTest {
@Test
fun test_itemListRoute_isSingleton() {
// Given & When
val route = Screen.ItemList
val route = Screen.ItemList()
// Then
assertEquals(Screen.ItemList, route)
assertEquals(Screen.ItemList(), route)
}
@Test
@ -73,4 +73,25 @@ class ScreenTest {
// Then
assert(route1 != route2)
}
@Test
fun test_itemListRoute_withoutCategoryId_hasNullCategoryId() {
// Given & When
val route = Screen.ItemList()
// Then
assertNull(route.categoryId)
}
@Test
fun test_itemListRoute_withCategoryId_hasCorrectCategoryId() {
// Given
val expectedId = 5
// When
val route = Screen.ItemList(categoryId = expectedId)
// Then
assertEquals(expectedId, route.categoryId)
}
}