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:
parent
7ccd2dc1fd
commit
bdd8cb4b11
7 changed files with 78 additions and 8 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue