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 index b0eb364..65b419c 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardScreen.kt @@ -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) { +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) { ) { 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) { - 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 + ) } } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsScreen.kt index da8a2a1..8563d6b 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsScreen.kt @@ -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 ) } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsUiState.kt new file mode 100644 index 0000000..10841fe --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsUiState.kt @@ -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 = emptyList(), + val minStockWarnings: List = 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 +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsViewModel.kt new file mode 100644 index 0000000..c66ef95 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/warnings/WarningsViewModel.kt @@ -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 = _uiState.asStateFlow() + + init { + viewModelScope.launch { + itemRepository.getAll().collect { items -> + _uiState.update { + WarningsUiState( + expiryWarnings = getExpiryWarnings(items), + minStockWarnings = getMinStockWarnings(items), + isLoading = false + ) + } + } + } + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt new file mode 100644 index 0000000..ccd5d83 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/warnings/WarningsViewModelTest.kt @@ -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>(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()) +} + +// endregion