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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun DashboardScreen( internal fun DashboardScreen(
onCategoryClick: (Int) -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -77,7 +78,10 @@ internal fun DashboardScreen(
) )
} }
items(uiState.categorySummaries) { summary -> 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 @Composable
private fun CategoryCard(summary: CategorySummary) { private fun CategoryCard(summary: CategorySummary, onClick: () -> Unit) {
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY) val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
Card( Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Row( Row(

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.ui.item 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.CategoryEntity
import de.bollwerk.app.data.db.entity.ItemEntity import de.bollwerk.app.data.db.entity.ItemEntity
import de.bollwerk.app.data.db.entity.LocationEntity import de.bollwerk.app.data.db.entity.LocationEntity
@ -40,6 +41,7 @@ class ItemListViewModelTest {
fakeCategoryRepository = FakeCategoryRepository() fakeCategoryRepository = FakeCategoryRepository()
fakeLocationRepository = FakeLocationRepository() fakeLocationRepository = FakeLocationRepository()
viewModel = ItemListViewModel( viewModel = ItemListViewModel(
savedStateHandle = SavedStateHandle(),
itemRepository = fakeItemRepository, itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository, categoryRepository = fakeCategoryRepository,
locationRepository = fakeLocationRepository locationRepository = fakeLocationRepository
@ -543,6 +545,40 @@ class ItemListViewModelTest {
assertEquals(2, viewModel.uiState.value.groupedItems.values.flatten().size) 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 // endregion
} }

View file

@ -9,10 +9,10 @@ class ScreenTest {
@Test @Test
fun test_itemListRoute_isSingleton() { fun test_itemListRoute_isSingleton() {
// Given & When // Given & When
val route = Screen.ItemList val route = Screen.ItemList()
// Then // Then
assertEquals(Screen.ItemList, route) assertEquals(Screen.ItemList(), route)
} }
@Test @Test
@ -73,4 +73,25 @@ class ScreenTest {
// Then // Then
assert(route1 != route2) 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)
}
} }