feat(dashboard): add Dashboard ViewModel & UI with overview, warnings, supply range

Implement the Dashboard screen (Issue #30) as the new start destination:

- DashboardUiState: data class with sections for category summaries,
  total value, supply range, expiry warnings, and min stock warnings
- DashboardViewModel: combines Item/Category flows with all five
  Use Cases from #29 (CalculateCategorySummary, CalculateTotalValue,
  CalculateSupplyRange, GetExpiryWarnings, GetMinStockWarnings)
- DashboardScreen: Material 3 layout with color-coded cards for
  summary overview, supply range (days), expiry warnings (red/orange),
  and min stock warnings (red), plus per-category cards
- Navigation: Dashboard added as startDestination, ItemListScreen
  gets a Dashboard menu entry for back-navigation
- 9 unit tests covering empty state, category summaries, total value,
  supply range, expiry/min-stock warnings, and reactive updates

Closes #30
This commit is contained in:
Jens Reinemann 2026-05-14 01:46:34 +02:00
parent 4cc7a781d2
commit b12684e6fc
7 changed files with 743 additions and 1 deletions

View file

@ -0,0 +1,319 @@
package de.krisenvorrat.app.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.app.domain.model.CategorySummary
import de.krisenvorrat.app.domain.model.ExpiryUrgency
import de.krisenvorrat.app.domain.model.ExpiryWarning
import de.krisenvorrat.app.domain.model.MinStockWarning
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun DashboardScreen(
onNavigateToItems: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Dashboard") },
actions = {
IconButton(onClick = onNavigateToItems) {
Icon(
imageVector = Icons.Default.List,
contentDescription = "Artikelliste"
)
}
}
)
}
) { padding ->
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SummaryCard(uiState) }
item { SupplyRangeCard(supplyRangeDays = uiState.supplyRangeDays) }
if (uiState.hasExpiryWarnings) {
item { ExpiryWarningsCard(warnings = uiState.expiryWarnings) }
}
if (uiState.hasMinStockWarnings) {
item { MinStockWarningsCard(warnings = uiState.minStockWarnings) }
}
if (uiState.categorySummaries.isNotEmpty()) {
item {
Text(
text = "Kategorien",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 4.dp)
)
}
items(uiState.categorySummaries) { summary ->
CategoryCard(summary)
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
}
}
}
@Composable
private fun SummaryCard(uiState: DashboardUiState) {
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Vorrat Übersicht",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "${uiState.totalItemCount}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "Artikel",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = currencyFormat.format(uiState.totalValue),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "Gesamtwert",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
@Composable
private fun SupplyRangeCard(supplyRangeDays: Double) {
val days = supplyRangeDays.roundToInt()
val containerColor = when {
days <= 3 -> MaterialTheme.colorScheme.errorContainer
days <= 14 -> Color(0xFFFFF3E0) // light orange
else -> MaterialTheme.colorScheme.secondaryContainer
}
val contentColor = when {
days <= 3 -> MaterialTheme.colorScheme.onErrorContainer
days <= 14 -> Color(0xFFE65100) // dark orange
else -> MaterialTheme.colorScheme.onSecondaryContainer
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Reichweite",
style = MaterialTheme.typography.titleMedium,
color = contentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (days > 0) "Vorrat reicht $days Tage" else "Keine Kalorienangaben vorhanden",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = contentColor
)
}
}
}
@Composable
private fun ExpiryWarningsCard(warnings: List<ExpiryWarning>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Ablaufdaten-Warnungen (${warnings.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
warnings.forEach { warning ->
val color = when (warning.urgency) {
ExpiryUrgency.URGENT -> MaterialTheme.colorScheme.error
ExpiryUrgency.WARNING -> Color(0xFFE65100)
}
val daysText = when {
warning.daysUntilExpiry < 0 -> "abgelaufen"
warning.daysUntilExpiry == 0L -> "läuft heute ab"
else -> "noch ${warning.daysUntilExpiry} Tage"
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = warning.item.name,
style = MaterialTheme.typography.bodyMedium,
color = color
)
Text(
text = daysText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = color
)
}
}
}
}
}
@Composable
private fun MinStockWarningsCard(warnings: List<MinStockWarning>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Mindestbestand unterschritten (${warnings.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
warnings.forEach { warning ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = warning.item.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
Text(
text = "fehlen: ${String.format(Locale.GERMANY, "%.1f", warning.deficit)} ${warning.item.unit}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
@Composable
private fun CategoryCard(summary: CategorySummary) {
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
Card(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = summary.categoryName,
style = MaterialTheme.typography.titleSmall
)
Text(
text = "${summary.itemCount} Artikel",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = currencyFormat.format(summary.totalValue),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
}
}

View file

@ -0,0 +1,23 @@
package de.krisenvorrat.app.ui.dashboard
import de.krisenvorrat.app.domain.model.CategorySummary
import de.krisenvorrat.app.domain.model.ExpiryWarning
import de.krisenvorrat.app.domain.model.MinStockWarning
internal data class DashboardUiState(
val categorySummaries: List<CategorySummary> = emptyList(),
val totalValue: Double = 0.0,
val supplyRangeDays: Double = 0.0,
val expiryWarnings: List<ExpiryWarning> = emptyList(),
val minStockWarnings: List<MinStockWarning> = emptyList(),
val isLoading: Boolean = true
) {
val totalItemCount: Int
get() = categorySummaries.sumOf { it.itemCount }
val hasExpiryWarnings: Boolean
get() = expiryWarnings.isNotEmpty()
val hasMinStockWarnings: Boolean
get() = minStockWarnings.isNotEmpty()
}

View file

@ -0,0 +1,54 @@
package de.krisenvorrat.app.ui.dashboard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class DashboardViewModel @Inject constructor(
private val itemRepository: ItemRepository,
private val categoryRepository: CategoryRepository,
private val calculateCategorySummary: CalculateCategorySummaryUseCase,
private val calculateTotalValue: CalculateTotalValueUseCase,
private val calculateSupplyRange: CalculateSupplyRangeUseCase,
private val getExpiryWarnings: GetExpiryWarningsUseCase,
private val getMinStockWarnings: GetMinStockWarningsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(DashboardUiState())
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
combine(
itemRepository.getAll(),
categoryRepository.getAll()
) { items, categories ->
DashboardUiState(
categorySummaries = calculateCategorySummary(items, categories),
totalValue = calculateTotalValue(items),
supplyRangeDays = calculateSupplyRange(items),
expiryWarnings = getExpiryWarnings(items),
minStockWarnings = getMinStockWarnings(items),
isLoading = false
)
}.collect { state ->
_uiState.update { state }
}
}
}
}

View file

@ -49,6 +49,7 @@ internal fun ItemListScreen(
onItemClick: (String) -> Unit, onItemClick: (String) -> Unit,
onCategoriesClick: () -> Unit, onCategoriesClick: () -> Unit,
onLocationsClick: () -> Unit, onLocationsClick: () -> Unit,
onDashboardClick: () -> Unit,
viewModel: ItemListViewModel = hiltViewModel() viewModel: ItemListViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -69,6 +70,13 @@ internal fun ItemListScreen(
expanded = isMenuExpanded, expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false } onDismissRequest = { isMenuExpanded = false }
) { ) {
DropdownMenuItem(
text = { Text("Dashboard") },
onClick = {
isMenuExpanded = false
onDashboardClick()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Kategorien") }, text = { Text("Kategorien") },
onClick = { onClick = {

View file

@ -5,6 +5,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import de.krisenvorrat.app.ui.category.CategoryListScreen import de.krisenvorrat.app.ui.category.CategoryListScreen
import de.krisenvorrat.app.ui.dashboard.DashboardScreen
import de.krisenvorrat.app.ui.item.ItemFormScreen import de.krisenvorrat.app.ui.item.ItemFormScreen
import de.krisenvorrat.app.ui.item.ItemListScreen import de.krisenvorrat.app.ui.item.ItemListScreen
import de.krisenvorrat.app.ui.location.LocationListScreen import de.krisenvorrat.app.ui.location.LocationListScreen
@ -13,8 +14,16 @@ import de.krisenvorrat.app.ui.location.LocationListScreen
internal fun KrisenvorratNavGraph(navController: NavHostController) { internal fun KrisenvorratNavGraph(navController: NavHostController) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.ItemList startDestination = Screen.Dashboard
) { ) {
composable<Screen.Dashboard> {
DashboardScreen(
onNavigateToItems = {
navController.navigate(Screen.ItemList)
}
)
}
composable<Screen.ItemList> { composable<Screen.ItemList> {
ItemListScreen( ItemListScreen(
onAddItem = { onAddItem = {
@ -28,6 +37,11 @@ internal fun KrisenvorratNavGraph(navController: NavHostController) {
}, },
onLocationsClick = { onLocationsClick = {
navController.navigate(Screen.LocationManagement) navController.navigate(Screen.LocationManagement)
},
onDashboardClick = {
navController.navigate(Screen.Dashboard) {
popUpTo(Screen.Dashboard) { inclusive = true }
}
} }
) )
} }

View file

@ -5,6 +5,9 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
internal sealed interface Screen { internal sealed interface Screen {
@Serializable
data object Dashboard : Screen
@Serializable @Serializable
data object ItemList : Screen data object ItemList : Screen

View file

@ -0,0 +1,321 @@
package de.krisenvorrat.app.ui.dashboard
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.time.LocalDate
@OptIn(ExperimentalCoroutinesApi::class)
class DashboardViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeItemRepository: FakeItemRepository
private lateinit var fakeCategoryRepository: FakeCategoryRepository
private lateinit var viewModel: DashboardViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeItemRepository = FakeItemRepository()
fakeCategoryRepository = FakeCategoryRepository()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel() = DashboardViewModel(
itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository,
calculateCategorySummary = CalculateCategorySummaryUseCase(),
calculateTotalValue = CalculateTotalValueUseCase(),
calculateSupplyRange = CalculateSupplyRangeUseCase(),
getExpiryWarnings = GetExpiryWarningsUseCase(),
getMinStockWarnings = GetMinStockWarningsUseCase()
)
@Test
fun test_init_withNoData_stateHasEmptyLists() = runTest(testDispatcher) {
// Given empty repositories
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertTrue(state.categorySummaries.isEmpty())
assertEquals(0.0, state.totalValue, 0.001)
assertEquals(0.0, state.supplyRangeDays, 0.001)
assertFalse(state.hasExpiryWarnings)
assertFalse(state.hasMinStockWarnings)
assertEquals(0, state.totalItemCount)
}
@Test
fun test_init_withItems_categorySummariesAreCalculated() = runTest(testDispatcher) {
// Given
fakeCategoryRepository.emit(
listOf(
CategoryEntity(id = 1, name = "Lebensmittel"),
CategoryEntity(id = 2, name = "Hygiene")
)
)
fakeItemRepository.emit(
listOf(
buildTestItem(id = "a", categoryId = 1, quantity = 2.0, unitPrice = 3.0),
buildTestItem(id = "b", categoryId = 1, quantity = 1.0, unitPrice = 5.0),
buildTestItem(id = "c", categoryId = 2, quantity = 4.0, unitPrice = 2.0)
)
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(2, state.categorySummaries.size)
assertEquals(3, state.totalItemCount)
val hygiene = state.categorySummaries.first { it.categoryName == "Hygiene" }
assertEquals(1, hygiene.itemCount)
assertEquals(8.0, hygiene.totalValue, 0.001)
val lebensmittel = state.categorySummaries.first { it.categoryName == "Lebensmittel" }
assertEquals(2, lebensmittel.itemCount)
assertEquals(11.0, lebensmittel.totalValue, 0.001)
}
@Test
fun test_init_withItems_totalValueIsCalculated() = runTest(testDispatcher) {
// Given
fakeItemRepository.emit(
listOf(
buildTestItem(id = "a", quantity = 2.0, unitPrice = 3.0),
buildTestItem(id = "b", quantity = 1.0, unitPrice = 5.0)
)
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
assertEquals(11.0, viewModel.uiState.value.totalValue, 0.001)
}
@Test
fun test_init_withKcalItems_supplyRangeIsCalculated() = runTest(testDispatcher) {
// Given 1kg item with 200 kcal/100g = 2000 kcal total
// Default: 2 persons * 2000 kcal/day = 4000 kcal/day → 0.5 days
fakeItemRepository.emit(
listOf(
buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 200)
)
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
assertEquals(0.5, viewModel.uiState.value.supplyRangeDays, 0.001)
}
@Test
fun test_init_withExpiringItems_expiryWarningsArePresent() = runTest(testDispatcher) {
// Given item expiring in 3 months (within URGENT_MONTHS=6)
val expiryDate = LocalDate.now().plusMonths(3)
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", expiryDate = expiryDate))
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.hasExpiryWarnings)
assertEquals(1, state.expiryWarnings.size)
}
@Test
fun test_init_withMinStockViolation_minStockWarningsArePresent() = runTest(testDispatcher) {
// Given item with quantity 1 but minStock 5
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", quantity = 1.0, minStock = 5.0))
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.hasMinStockWarnings)
assertEquals(1, state.minStockWarnings.size)
assertEquals(4.0, state.minStockWarnings.first().deficit, 0.001)
}
@Test
fun test_init_withSufficientStock_noMinStockWarnings() = runTest(testDispatcher) {
// Given item with quantity above minStock
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", quantity = 10.0, minStock = 5.0))
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.hasMinStockWarnings)
}
@Test
fun test_init_withFarExpiryDate_noExpiryWarnings() = runTest(testDispatcher) {
// Given item expiring in 2 years (beyond WARNING_MONTHS=12)
val expiryDate = LocalDate.now().plusYears(2)
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", expiryDate = expiryDate))
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.hasExpiryWarnings)
}
@Test
fun test_reactiveUpdate_whenItemsChange_stateUpdates() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
assertEquals(0, viewModel.uiState.value.totalItemCount)
// When items appear
fakeCategoryRepository.emit(listOf(CategoryEntity(id = 1, name = "Test")))
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", categoryId = 1, quantity = 2.0, unitPrice = 5.0))
)
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals(1, state.totalItemCount)
assertEquals(10.0, state.totalValue, 0.001)
}
}
// region Test Helpers
private fun buildTestItem(
id: String = "id1",
name: String = "Konserve",
categoryId: Int = 1,
quantity: Double = 1.0,
unit: String = "Stk",
unitPrice: Double = 0.0,
kcalPer100g: Int? = null,
expiryDate: LocalDate? = null,
locationId: Int = 1,
minStock: Double = 0.0
) = ItemEntity(
id = id,
name = name,
categoryId = categoryId,
quantity = quantity,
unit = unit,
unitPrice = unitPrice,
kcalPer100g = kcalPer100g,
expiryDate = expiryDate,
locationId = locationId,
minStock = minStock,
notes = "",
lastUpdated = 0L
)
private class FakeItemRepository : ItemRepository {
private val flow = MutableStateFlow<List<ItemEntity>>(emptyList())
fun emit(items: List<ItemEntity>) {
flow.value = items
}
override fun getAll(): Flow<List<ItemEntity>> = flow
override suspend fun getById(id: String): ItemEntity? =
flow.value.find { it.id == id }
override suspend fun insert(item: ItemEntity) {
flow.value = flow.value + item
}
override suspend fun update(item: ItemEntity) {
flow.value = flow.value.map { if (it.id == item.id) item else it }
}
override suspend fun delete(item: ItemEntity) {
flow.value = flow.value.filter { it.id != item.id }
}
override fun getByCategory(categoryId: Int): Flow<List<ItemEntity>> =
MutableStateFlow(flow.value.filter { it.categoryId == categoryId })
override fun getByLocation(locationId: Int): Flow<List<ItemEntity>> =
MutableStateFlow(flow.value.filter { it.locationId == locationId })
override fun getExpiringSoon(daysUntil: Int): Flow<List<ItemEntity>> =
MutableStateFlow(emptyList())
}
private class FakeCategoryRepository : CategoryRepository {
private val flow = MutableStateFlow<List<CategoryEntity>>(emptyList())
fun emit(categories: List<CategoryEntity>) {
flow.value = categories
}
override fun getAll(): Flow<List<CategoryEntity>> = flow
override suspend fun insert(category: CategoryEntity) {
flow.value = flow.value + category
}
override suspend fun update(category: CategoryEntity) {
flow.value = flow.value.map { if (it.id == category.id) category else it }
}
override suspend fun delete(category: CategoryEntity) {
flow.value = flow.value.filter { it.id != category.id }
}
}
// endregion