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,
|
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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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