feat(warnings): implement dedicated Warnings screen with ViewModel

ui/warnings/WarningsScreen.kt: full implementation replacing placeholder.
Shows expiry warnings (colored by urgency: URGENT=error, WARNING=orange)
and min-stock warnings as individual cards in a LazyColumn. Displays
empty state when no warnings exist.

ui/warnings/WarningsViewModel.kt: HiltViewModel observing ItemRepository
flow, delegates to GetExpiryWarningsUseCase and GetMinStockWarningsUseCase.
Exposes WarningsUiState via StateFlow.

ui/warnings/WarningsUiState.kt: data class with expiryWarnings,
minStockWarnings, isLoading, and derived properties (totalWarningCount,
hasWarnings).

ui/dashboard/DashboardScreen.kt: replaced ExpiryWarningsCard and
MinStockWarningsCard with compact WarningsSummaryCard showing only
warning counts. Removed unused domain model imports.

tests: 7 WarningsViewModel unit tests covering empty state, expiry
warnings, min-stock warnings, combined counts, reactive updates.

Closes #34
This commit is contained in:
Jens Reinemann 2026-05-14 02:39:46 +02:00
parent a4c0dc63b4
commit 34bd1f603f
5 changed files with 472 additions and 85 deletions

View file

@ -27,9 +27,6 @@ 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
@ -63,12 +60,13 @@ internal fun DashboardScreen(
item { SupplyRangeCard(supplyRangeDays = uiState.supplyRangeDays) }
if (uiState.hasExpiryWarnings) {
item { ExpiryWarningsCard(warnings = uiState.expiryWarnings) }
}
if (uiState.hasMinStockWarnings) {
item { MinStockWarningsCard(warnings = uiState.minStockWarnings) }
if (uiState.hasExpiryWarnings || uiState.hasMinStockWarnings) {
item {
WarningsSummaryCard(
expiryCount = uiState.expiryWarnings.size,
minStockCount = uiState.minStockWarnings.size
)
}
}
if (uiState.categorySummaries.isNotEmpty()) {
@ -178,7 +176,8 @@ private fun SupplyRangeCard(supplyRangeDays: Double) {
}
@Composable
private fun ExpiryWarningsCard(warnings: List<ExpiryWarning>) {
private fun WarningsSummaryCard(expiryCount: Int, minStockCount: Int) {
val totalCount = expiryCount + minStockCount
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@ -187,78 +186,24 @@ private fun ExpiryWarningsCard(warnings: List<ExpiryWarning>) {
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Ablaufdaten-Warnungen (${warnings.size})",
text = "⚠️ $totalCount Warnungen",
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
)
}
Spacer(modifier = Modifier.height(4.dp))
if (expiryCount > 0) {
Text(
text = "$expiryCount Ablaufdaten-Warnungen",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
@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
)
}
if (minStockCount > 0) {
Text(
text = "$minStockCount Mindestbestand-Warnungen",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}

View file

@ -1,33 +1,175 @@
package de.krisenvorrat.app.ui.warnings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.ExpiryUrgency
import de.krisenvorrat.app.domain.model.ExpiryWarning
import de.krisenvorrat.app.domain.model.MinStockWarning
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WarningsScreen() {
internal fun WarningsScreen(
viewModel: WarningsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(title = { Text("Warnungen") })
Box(
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (!uiState.hasWarnings) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Keine Warnungen vorhanden",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item { Spacer(modifier = Modifier.height(4.dp)) }
if (uiState.hasExpiryWarnings) {
item {
Text(
text = "Ablaufdaten (${uiState.expiryWarnings.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 4.dp)
)
}
items(uiState.expiryWarnings) { warning ->
ExpiryWarningItem(warning)
}
}
if (uiState.hasMinStockWarnings) {
item {
Text(
text = "Mindestbestand unterschritten (${uiState.minStockWarnings.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 4.dp)
)
}
items(uiState.minStockWarnings) { warning ->
MinStockWarningItem(warning)
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
}
}
}
@Composable
private fun ExpiryWarningItem(warning: ExpiryWarning) {
val containerColor = when (warning.urgency) {
ExpiryUrgency.URGENT -> MaterialTheme.colorScheme.errorContainer
ExpiryUrgency.WARNING -> Color(0xFFFFF3E0)
}
val contentColor = when (warning.urgency) {
ExpiryUrgency.URGENT -> MaterialTheme.colorScheme.onErrorContainer
ExpiryUrgency.WARNING -> Color(0xFFE65100)
}
val daysText = when {
warning.daysUntilExpiry < 0 -> "abgelaufen"
warning.daysUntilExpiry == 0L -> "läuft heute ab"
else -> "noch ${warning.daysUntilExpiry} Tage"
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Row(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Warnungen werden in einem späteren Schritt implementiert.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
text = warning.item.name,
style = MaterialTheme.typography.bodyMedium,
color = contentColor
)
Text(
text = daysText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor
)
}
}
}
@Composable
private fun MinStockWarningItem(warning: MinStockWarning) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = warning.item.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "fehlen: ${String.format(Locale.GERMANY, "%.1f", warning.deficit)} ${warning.item.unit}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
}

View file

@ -0,0 +1,22 @@
package de.krisenvorrat.app.ui.warnings
import de.krisenvorrat.app.domain.model.ExpiryWarning
import de.krisenvorrat.app.domain.model.MinStockWarning
internal data class WarningsUiState(
val expiryWarnings: List<ExpiryWarning> = emptyList(),
val minStockWarnings: List<MinStockWarning> = emptyList(),
val isLoading: Boolean = true
) {
val hasExpiryWarnings: Boolean
get() = expiryWarnings.isNotEmpty()
val hasMinStockWarnings: Boolean
get() = minStockWarnings.isNotEmpty()
val totalWarningCount: Int
get() = expiryWarnings.size + minStockWarnings.size
val hasWarnings: Boolean
get() = hasExpiryWarnings || hasMinStockWarnings
}

View file

@ -0,0 +1,39 @@
package de.krisenvorrat.app.ui.warnings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.ItemRepository
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.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class WarningsViewModel @Inject constructor(
private val itemRepository: ItemRepository,
private val getExpiryWarnings: GetExpiryWarningsUseCase,
private val getMinStockWarnings: GetMinStockWarningsUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(WarningsUiState())
val uiState: StateFlow<WarningsUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
itemRepository.getAll().collect { items ->
_uiState.update {
WarningsUiState(
expiryWarnings = getExpiryWarnings(items),
minStockWarnings = getMinStockWarnings(items),
isLoading = false
)
}
}
}
}
}

View file

@ -0,0 +1,239 @@
package de.krisenvorrat.app.ui.warnings
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.domain.repository.ItemRepository
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 WarningsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeItemRepository: FakeItemRepository
private lateinit var viewModel: WarningsViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeItemRepository = FakeItemRepository()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel() = WarningsViewModel(
itemRepository = fakeItemRepository,
getExpiryWarnings = GetExpiryWarningsUseCase(),
getMinStockWarnings = GetMinStockWarningsUseCase()
)
@Test
fun test_init_withNoData_stateHasEmptyLists() = runTest(testDispatcher) {
// Given empty repository
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertFalse(state.hasExpiryWarnings)
assertFalse(state.hasMinStockWarnings)
assertFalse(state.hasWarnings)
assertEquals(0, state.totalWarningCount)
}
@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)
assertTrue(state.hasWarnings)
assertEquals(1, state.totalWarningCount)
}
@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)
assertTrue(state.hasWarnings)
assertEquals(1, state.totalWarningCount)
}
@Test
fun test_init_withBothWarnings_totalCountIsCombined() = runTest(testDispatcher) {
// Given one expiring item and one below min stock
val expiryDate = LocalDate.now().plusMonths(3)
fakeItemRepository.emit(
listOf(
buildTestItem(id = "a", expiryDate = expiryDate),
buildTestItem(id = "b", quantity = 1.0, minStock = 5.0)
)
)
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.hasExpiryWarnings)
assertTrue(state.hasMinStockWarnings)
assertEquals(2, state.totalWarningCount)
}
@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_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_reactiveUpdate_whenItemsChange_stateUpdates() = runTest(testDispatcher) {
// Given start with no warnings
viewModel = createViewModel()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.hasWarnings)
// When an item below min stock appears
fakeItemRepository.emit(
listOf(buildTestItem(id = "a", quantity = 1.0, minStock = 5.0))
)
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.hasMinStockWarnings)
assertEquals(1, viewModel.uiState.value.totalWarningCount)
}
}
// 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())
}
// endregion