diff --git a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..ba5ca8f --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardScreen.kt @@ -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) { + 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) { + 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 + ) + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardUiState.kt new file mode 100644 index 0000000..e5661b5 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardUiState.kt @@ -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 = emptyList(), + val totalValue: Double = 0.0, + val supplyRangeDays: Double = 0.0, + val expiryWarnings: List = emptyList(), + val minStockWarnings: List = 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() +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..f956240 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt @@ -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 = _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 } + } + } + } +} 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 9b44a94..be8d6ac 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 @@ -49,6 +49,7 @@ internal fun ItemListScreen( onItemClick: (String) -> Unit, onCategoriesClick: () -> Unit, onLocationsClick: () -> Unit, + onDashboardClick: () -> Unit, viewModel: ItemListViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -69,6 +70,13 @@ internal fun ItemListScreen( expanded = isMenuExpanded, onDismissRequest = { isMenuExpanded = false } ) { + DropdownMenuItem( + text = { Text("Dashboard") }, + onClick = { + isMenuExpanded = false + onDashboardClick() + } + ) DropdownMenuItem( text = { Text("Kategorien") }, onClick = { diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt index 523e4b5..baade69 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/KrisenvorratNavGraph.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable 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.ItemListScreen import de.krisenvorrat.app.ui.location.LocationListScreen @@ -13,8 +14,16 @@ import de.krisenvorrat.app.ui.location.LocationListScreen internal fun KrisenvorratNavGraph(navController: NavHostController) { NavHost( navController = navController, - startDestination = Screen.ItemList + startDestination = Screen.Dashboard ) { + composable { + DashboardScreen( + onNavigateToItems = { + navController.navigate(Screen.ItemList) + } + ) + } + composable { ItemListScreen( onAddItem = { @@ -28,6 +37,11 @@ internal fun KrisenvorratNavGraph(navController: NavHostController) { }, onLocationsClick = { navController.navigate(Screen.LocationManagement) + }, + onDashboardClick = { + navController.navigate(Screen.Dashboard) { + popUpTo(Screen.Dashboard) { inclusive = true } + } } ) } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt b/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt index 4721c96..0230d9a 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/navigation/Screen.kt @@ -5,6 +5,9 @@ import kotlinx.serialization.Serializable @Serializable internal sealed interface Screen { + @Serializable + data object Dashboard : Screen + @Serializable data object ItemList : Screen diff --git a/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt new file mode 100644 index 0000000..43c2359 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt @@ -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>(emptyList()) + + fun emit(items: List) { + flow.value = items + } + + override fun getAll(): Flow> = 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> = + MutableStateFlow(flow.value.filter { it.categoryId == categoryId }) + + override fun getByLocation(locationId: Int): Flow> = + MutableStateFlow(flow.value.filter { it.locationId == locationId }) + + override fun getExpiringSoon(daysUntil: Int): Flow> = + MutableStateFlow(emptyList()) +} + +private class FakeCategoryRepository : CategoryRepository { + private val flow = MutableStateFlow>(emptyList()) + + fun emit(categories: List) { + flow.value = categories + } + + override fun getAll(): Flow> = 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