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:
parent
4cc7a781d2
commit
b12684e6fc
7 changed files with 743 additions and 1 deletions
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<Screen.Dashboard> {
|
||||
DashboardScreen(
|
||||
onNavigateToItems = {
|
||||
navController.navigate(Screen.ItemList)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.ItemList> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
internal sealed interface Screen {
|
||||
|
||||
@Serializable
|
||||
data object Dashboard : Screen
|
||||
|
||||
@Serializable
|
||||
data object ItemList : Screen
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in a new issue