diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt index 74b5a3d..b673c99 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -8,6 +8,8 @@ import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.ItemEntity import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson +import de.krisenvorrat.app.domain.model.totalDailyKcal import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.shared.model.CategoryDto import de.krisenvorrat.shared.model.InventoryDto @@ -143,14 +145,15 @@ internal class ImportExportRepositoryImpl @Inject constructor( } val settingsMap = settings.associate { it.key to it.value } - val householdSize = settingsMap["household_size"] - val kcalPerDay = settingsMap["kcal_per_day"] - if (householdSize != null || kcalPerDay != null) { - sb.appendLine("## Einstellungen") - sb.appendLine() - if (householdSize != null) sb.appendLine("- **Haushaltsgröße:** $householdSize Personen") - if (kcalPerDay != null) sb.appendLine("- **kcal/Tag:** $kcalPerDay") - sb.appendLine() + val ageGroupsJson = settingsMap["age_groups"] + if (ageGroupsJson != null) { + val totalDailyKcal = parseAgeGroupsFromJson(ageGroupsJson).totalDailyKcal() + if (totalDailyKcal > 0) { + sb.appendLine("## Einstellungen") + sb.appendLine() + sb.appendLine("- **Haushalt kcal/Tag:** $totalDailyKcal") + sb.appendLine() + } } sb.toString().trimEnd() + "\n" diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/AgeGroup.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/AgeGroup.kt new file mode 100644 index 0000000..20ac2ab --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/AgeGroup.kt @@ -0,0 +1,50 @@ +package de.krisenvorrat.app.domain.model + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +enum class AgeGroup(val label: String, val defaultKcal: Int) { + KLEINKIND("Kleinkind (1–3 J.)", 1000), + KIND_4_6("Kind (4–6 J.)", 1400), + KIND_7_9("Kind (7–9 J.)", 1700), + KIND_10_12("Kind (10–12 J.)", 2000), + JUGENDLICH("Jugendlich (13–17 J.)", 2400), + ERWACHSENER("Erwachsener", 2000) +} + +@Serializable +data class AgeGroupEntry( + val ageGroup: AgeGroup, + val count: Int, + val kcalPerDay: Int +) + +internal fun List.totalDailyKcal(): Int = sumOf { it.count * it.kcalPerDay } + +internal fun defaultAgeGroups(): List = AgeGroup.entries.map { group -> + AgeGroupEntry( + ageGroup = group, + count = if (group == AgeGroup.ERWACHSENER) 2 else 0, + kcalPerDay = group.defaultKcal + ) +} + +private val ageGroupsJson = Json { ignoreUnknownKeys = true } + +internal fun List.toJson(): String = ageGroupsJson.encodeToString( + kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()), + this +) + +internal fun parseAgeGroupsFromJson(json: String?): List { + if (json.isNullOrBlank()) return defaultAgeGroups() + return try { + ageGroupsJson.decodeFromString( + kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()), + json + ) + } catch (e: Exception) { + defaultAgeGroups() + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt new file mode 100644 index 0000000..86544ac --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt @@ -0,0 +1,10 @@ +package de.krisenvorrat.app.domain.model + +internal object SettingsKeys { + const val HOUSEHOLD_SIZE = "household_size" + const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" + const val AGE_GROUPS = "age_groups" + const val SERVER_URL = "server_url" + const val API_KEY = "api_key" + const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp" +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCase.kt b/app/src/main/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCase.kt index 53e2694..7a492a4 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCase.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCase.kt @@ -8,14 +8,14 @@ internal class CalculateSupplyRangeUseCase @Inject constructor() { companion object { const val DEFAULT_HOUSEHOLD_SIZE = 2 const val DEFAULT_DAILY_KCAL_PER_PERSON = 2000 + const val DEFAULT_TOTAL_DAILY_KCAL = DEFAULT_HOUSEHOLD_SIZE * DEFAULT_DAILY_KCAL_PER_PERSON } operator fun invoke( items: List, - householdSize: Int = DEFAULT_HOUSEHOLD_SIZE, - dailyKcalPerPerson: Int = DEFAULT_DAILY_KCAL_PER_PERSON + totalDailyKcal: Int = DEFAULT_TOTAL_DAILY_KCAL ): Double { - val dailyNeed = householdSize * dailyKcalPerPerson + val dailyNeed = totalDailyKcal if (dailyNeed <= 0) return 0.0 val totalKcal = items.sumOf { item -> diff --git a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt index 5e6f3c8..81a110b 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModel.kt @@ -3,7 +3,9 @@ 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.model.parseAgeGroupsFromJson +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.model.totalDailyKcal import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase @@ -11,10 +13,10 @@ 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 de.krisenvorrat.app.ui.settings.SettingsViewModel -import kotlinx.coroutines.flow.MutableStateFlow +import de.krisenvorrat.app.domain.repository.CategoryRepository import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -40,18 +42,17 @@ internal class DashboardViewModel @Inject constructor( combine( itemRepository.getAll(), categoryRepository.getAll(), - settingsRepository.observeValue(SettingsViewModel.KEY_HOUSEHOLD_SIZE), - settingsRepository.observeValue(SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON) - ) { items, categories, householdSizeStr, dailyKcalStr -> - val householdSize = householdSizeStr?.toIntOrNull() - ?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE - val dailyKcal = dailyKcalStr?.toIntOrNull() - ?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON + settingsRepository.observeValue(SettingsKeys.AGE_GROUPS) + ) { items, categories, ageGroupsJson -> + val ageGroups = parseAgeGroupsFromJson(ageGroupsJson) + val totalDailyKcal = ageGroups.totalDailyKcal().let { + if (it <= 0) CalculateSupplyRangeUseCase.DEFAULT_TOTAL_DAILY_KCAL else it + } DashboardUiState( categorySummaries = calculateCategorySummary(items, categories), totalValue = calculateTotalValue(items), - supplyRangeDays = calculateSupplyRange(items, householdSize, dailyKcal), + supplyRangeDays = calculateSupplyRange(items, totalDailyKcal), expiryWarnings = getExpiryWarnings(items), minStockWarnings = getMinStockWarnings(items), isLoading = false diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index 7a11594..4dcca88 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.krisenvorrat.app.domain.model.AgeGroup +import de.krisenvorrat.app.domain.model.totalDailyKcal @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -105,25 +107,45 @@ internal fun SettingsScreen( Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( - value = uiState.householdSize, - onValueChange = viewModel::onHouseholdSizeChanged, - label = { Text("Personenzahl") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) + uiState.ageGroups.forEach { entry -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = entry.ageGroup.label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = entry.count.toString(), + onValueChange = { viewModel.onAgeGroupCountChanged(entry.ageGroup, it) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.width(56.dp) + ) + Text("×", style = MaterialTheme.typography.bodyMedium) + OutlinedTextField( + value = entry.kcalPerDay.toString(), + onValueChange = { viewModel.onAgeGroupKcalChanged(entry.ageGroup, it) }, + label = { Text("kcal/Tag") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.width(104.dp) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = uiState.dailyKcalPerPerson, - onValueChange = viewModel::onDailyKcalChanged, - label = { Text("kcal pro Tag pro Person") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) + val totalKcal = uiState.ageGroups.totalDailyKcal() + if (totalKcal > 0) { + Text( + text = "Gesamt: $totalKcal kcal/Tag", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index 5bbd471..2e6cedc 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -1,10 +1,11 @@ package de.krisenvorrat.app.ui.settings import android.net.Uri +import de.krisenvorrat.app.domain.model.AgeGroupEntry +import de.krisenvorrat.app.domain.model.defaultAgeGroups internal data class SettingsUiState( - val householdSize: String = "", - val dailyKcalPerPerson: String = "", + val ageGroups: List = defaultAgeGroups(), val isLoading: Boolean = true, val isSaved: Boolean = false, val isExporting: Boolean = false, diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index f1343cb..fa1717f 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -7,10 +7,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import de.krisenvorrat.app.domain.model.AgeGroup +import de.krisenvorrat.app.domain.model.AgeGroupEntry +import de.krisenvorrat.app.domain.model.SettingsKeys +import de.krisenvorrat.app.domain.model.defaultAgeGroups +import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson +import de.krisenvorrat.app.domain.model.toJson import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SyncService -import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,18 +46,14 @@ internal class SettingsViewModel @Inject constructor( private fun loadSettings() { viewModelScope.launch { try { - val householdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE) - ?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString() - val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON) - ?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString() + val ageGroups = loadAgeGroupsWithMigration() val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: "" val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: "" val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP) _uiState.update { it.copy( - householdSize = householdSize, - dailyKcalPerPerson = dailyKcal, + ageGroups = ageGroups, serverUrl = serverUrl, apiKey = apiKey, lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) }, @@ -62,8 +63,7 @@ internal class SettingsViewModel @Inject constructor( } catch (e: Exception) { _uiState.update { it.copy( - householdSize = CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(), - dailyKcalPerPerson = CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(), + ageGroups = defaultAgeGroups(), isLoading = false ) } @@ -71,12 +71,48 @@ internal class SettingsViewModel @Inject constructor( } } - fun onHouseholdSizeChanged(value: String) { - _uiState.update { it.copy(householdSize = value, isSaved = false) } + private suspend fun loadAgeGroupsWithMigration(): List { + val ageGroupsJsonValue = settingsRepository.getValue(KEY_AGE_GROUPS) + if (ageGroupsJsonValue != null) { + return parseAgeGroupsFromJson(ageGroupsJsonValue) + } + val legacyHouseholdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE)?.toIntOrNull() + if (legacyHouseholdSize != null) { + val legacyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)?.toIntOrNull() + ?: AgeGroup.ERWACHSENER.defaultKcal + return defaultAgeGroups().map { entry -> + if (entry.ageGroup == AgeGroup.ERWACHSENER) { + entry.copy(count = legacyHouseholdSize, kcalPerDay = legacyKcal) + } else { + entry + } + } + } + return defaultAgeGroups() } - fun onDailyKcalChanged(value: String) { - _uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) } + fun onAgeGroupCountChanged(ageGroup: AgeGroup, count: String) { + val parsed = count.toIntOrNull()?.takeIf { it >= 0 } ?: return + _uiState.update { state -> + state.copy( + ageGroups = state.ageGroups.map { entry -> + if (entry.ageGroup == ageGroup) entry.copy(count = parsed) else entry + }, + isSaved = false + ) + } + } + + fun onAgeGroupKcalChanged(ageGroup: AgeGroup, kcal: String) { + val parsed = kcal.toIntOrNull()?.takeIf { it > 0 } ?: return + _uiState.update { state -> + state.copy( + ageGroups = state.ageGroups.map { entry -> + if (entry.ageGroup == ageGroup) entry.copy(kcalPerDay = parsed) else entry + }, + isSaved = false + ) + } } fun onServerUrlChanged(value: String) { @@ -90,16 +126,7 @@ internal class SettingsViewModel @Inject constructor( fun saveSettings() { viewModelScope.launch { try { - val householdSize = _uiState.value.householdSize - val dailyKcal = _uiState.value.dailyKcalPerPerson - - if (householdSize.toIntOrNull() != null && householdSize.toInt() > 0) { - settingsRepository.setValue(KEY_HOUSEHOLD_SIZE, householdSize) - } - if (dailyKcal.toIntOrNull() != null && dailyKcal.toInt() > 0) { - settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal) - } - + settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson()) settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl) settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey) @@ -272,10 +299,11 @@ internal class SettingsViewModel @Inject constructor( } companion object { - const val KEY_HOUSEHOLD_SIZE = "household_size" - const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" - const val KEY_SERVER_URL = "server_url" - const val KEY_API_KEY = "api_key" - const val KEY_SYNC_LAST_TIMESTAMP = "sync_last_timestamp" + val KEY_HOUSEHOLD_SIZE = SettingsKeys.HOUSEHOLD_SIZE + val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON + val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS + val KEY_SERVER_URL = SettingsKeys.SERVER_URL + val KEY_API_KEY = SettingsKeys.API_KEY + val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP } } diff --git a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt index 2af95f8..961a1c6 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImplTest.kt @@ -3,6 +3,9 @@ package de.krisenvorrat.app.data.export import de.krisenvorrat.app.data.db.entity.CategoryEntity import de.krisenvorrat.app.data.db.entity.LocationEntity import de.krisenvorrat.app.data.db.entity.SettingsEntity +import de.krisenvorrat.app.domain.model.AgeGroup +import de.krisenvorrat.app.domain.model.AgeGroupEntry +import de.krisenvorrat.app.domain.model.toJson import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -188,9 +191,11 @@ class ImportExportRepositoryImplTest { val categoryDao = FakeCategoryDao() val settingsDao = FakeSettingsDao() categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test"))) + val ageGroups = listOf( + AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 4, kcalPerDay = 2000) + ) settingsDao.upsertAll(listOf( - SettingsEntity(key = "household_size", value = "4"), - SettingsEntity(key = "kcal_per_day", value = "2000") + SettingsEntity(key = "age_groups", value = ageGroups.toJson()) )) val repository = buildRepository(categoryDao, settingsDao = settingsDao) @@ -199,8 +204,7 @@ class ImportExportRepositoryImplTest { // Then assertTrue(markdown.contains("## Einstellungen")) - assertTrue(markdown.contains("**Haushaltsgröße:** 4 Personen")) - assertTrue(markdown.contains("**kcal/Tag:** 2000")) + assertTrue(markdown.contains("**Haushalt kcal/Tag:** 8000")) } @Test diff --git a/app/src/test/java/de/krisenvorrat/app/domain/model/AgeGroupTest.kt b/app/src/test/java/de/krisenvorrat/app/domain/model/AgeGroupTest.kt new file mode 100644 index 0000000..6314e80 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/domain/model/AgeGroupTest.kt @@ -0,0 +1,62 @@ +package de.krisenvorrat.app.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgeGroupTest { + + @Test + fun test_totalDailyKcal_withMixedGroups_returnsSumOfCountTimesKcal() { + // Given + val entries = listOf( + AgeGroupEntry(ageGroup = AgeGroup.KLEINKIND, count = 1, kcalPerDay = 1000), + AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 2, kcalPerDay = 2000), + AgeGroupEntry(ageGroup = AgeGroup.JUGENDLICH, count = 0, kcalPerDay = 2400) + ) + + // When + val result = entries.totalDailyKcal() + + // Then – 1×1000 + 2×2000 + 0×2400 = 5000 + assertEquals(5000, result) + } + + @Test + fun test_parseAgeGroupsFromJson_withValidJson_returnsCorrectGroups() { + // Given + val entries = listOf( + AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 3, kcalPerDay = 2500) + ) + val json = entries.toJson() + + // When + val parsed = parseAgeGroupsFromJson(json) + + // Then + assertEquals(1, parsed.size) + assertEquals(AgeGroup.ERWACHSENER, parsed.first().ageGroup) + assertEquals(3, parsed.first().count) + assertEquals(2500, parsed.first().kcalPerDay) + } + + @Test + fun test_parseAgeGroupsFromJson_withNull_returnsDefaults() { + // Given / When + val result = parseAgeGroupsFromJson(null) + + // Then + assertEquals(defaultAgeGroups(), result) + } + + @Test + fun test_defaultAgeGroups_hasErwachsenerWithCount2() { + // Given / When + val defaults = defaultAgeGroups() + + // Then + val erwachsener = defaults.first { it.ageGroup == AgeGroup.ERWACHSENER } + assertEquals(2, erwachsener.count) + assertTrue(defaults.filter { it.ageGroup != AgeGroup.ERWACHSENER }.all { it.count == 0 }) + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCaseTest.kt b/app/src/test/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCaseTest.kt index aa62ab3..0de8578 100644 --- a/app/src/test/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCaseTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/domain/usecase/CalculateSupplyRangeUseCaseTest.kt @@ -10,13 +10,13 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withKgItems_returnsCorrectDays() { // Given – 2 kg Reis à 350 kcal/100g = 7000 kcal - // 2 Personen × 2000 kcal/Tag = 4000 kcal/Tag → 1.75 Tage + // 4000 kcal/Tag (2 × 2000) → 1.75 Tage val items = listOf( buildTestItem(id = "1", quantity = 2.0, unit = "kg", kcalPer100g = 350) ) // When - val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 4000) // Then assertEquals(1.75, result, 0.001) @@ -25,13 +25,13 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withGramItems_returnsCorrectDays() { // Given – 500 g Nudeln à 360 kcal/100g = 1800 kcal - // 1 Person × 2000 kcal/Tag → 0.9 Tage + // 2000 kcal/Tag → 0.9 Tage val items = listOf( buildTestItem(id = "1", quantity = 500.0, unit = "g", kcalPer100g = 360) ) // When - val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 2000) // Then assertEquals(0.9, result, 0.001) @@ -40,14 +40,14 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withMultipleItems_sumsTotalKcal() { // Given – 1 kg Reis (350 kcal/100g = 3500 kcal) + 500 g Nudeln (360 kcal/100g = 1800 kcal) = 5300 kcal - // 2 Personen × 2000 kcal/Tag = 4000 kcal/Tag → 1.325 Tage + // 4000 kcal/Tag → 1.325 Tage val items = listOf( buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350), buildTestItem(id = "2", quantity = 500.0, unit = "g", kcalPer100g = 360) ) // When - val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 4000) // Then assertEquals(1.325, result, 0.001) @@ -56,7 +56,7 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withEmptyList_returnsZero() { // Given / When - val result = useCase(emptyList(), householdSize = 2, dailyKcalPerPerson = 2000) + val result = useCase(emptyList(), totalDailyKcal = 4000) // Then assertEquals(0.0, result, 0.001) @@ -70,8 +70,8 @@ class CalculateSupplyRangeUseCaseTest { buildTestItem(id = "2", quantity = 1.0, unit = "kg", kcalPer100g = 200) ) - // When – Nur 1 kg à 200 kcal/100g = 2000 kcal, 1 Person × 2000 = 1.0 Tage - val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000) + // When – Nur 1 kg à 200 kcal/100g = 2000 kcal, 2000 kcal/Tag = 1.0 Tage + val result = useCase(items, totalDailyKcal = 2000) // Then assertEquals(1.0, result, 0.001) @@ -85,30 +85,30 @@ class CalculateSupplyRangeUseCaseTest { ) // When - val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 4000) // Then assertEquals(0.0, result, 0.001) } @Test - fun test_invoke_withZeroHouseholdSize_returnsZero() { + fun test_invoke_withZeroTotalDailyKcal_returnsZero() { // Given val items = listOf( buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350) ) // When - val result = useCase(items, householdSize = 0, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 0) // Then assertEquals(0.0, result, 0.001) } @Test - fun test_invoke_withDefaultParameters_uses2PersonsAnd2000Kcal() { + fun test_invoke_withDefaultParameters_uses4000KcalPerDay() { // Given – 4 kg Reis à 350 kcal/100g = 14000 kcal - // Default: 2 Personen × 2000 kcal = 4000 kcal/Tag → 3.5 Tage + // Default: 4000 kcal/Tag → 3.5 Tage val items = listOf( buildTestItem(id = "1", quantity = 4.0, unit = "kg", kcalPer100g = 350) ) @@ -123,13 +123,13 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withMgUnit_convertsCorrectly() { // Given – 500000 mg = 500 g à 200 kcal/100g = 1000 kcal - // 1 Person × 1000 kcal/Tag → 1.0 Tag + // 1000 kcal/Tag → 1.0 Tag val items = listOf( buildTestItem(id = "1", quantity = 500000.0, unit = "mg", kcalPer100g = 200) ) // When - val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 1000) + val result = useCase(items, totalDailyKcal = 1000) // Then assertEquals(1.0, result, 0.001) @@ -138,7 +138,7 @@ class CalculateSupplyRangeUseCaseTest { @Test fun test_invoke_withMixedUnits_onlyCountsWeightBased() { // Given – 1 kg (350 kcal/100g) + 5 Stk (ignored) + 2 L (ignored) - // Total: 3500 kcal, 1 Person × 2000 → 1.75 Tage + // Total: 3500 kcal, 2000 kcal/Tag → 1.75 Tage val items = listOf( buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350), buildTestItem(id = "2", quantity = 5.0, unit = "Stk", kcalPer100g = 100), @@ -146,7 +146,7 @@ class CalculateSupplyRangeUseCaseTest { ) // When - val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000) + val result = useCase(items, totalDailyKcal = 2000) // Then assertEquals(1.75, result, 0.001) diff --git a/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt index 59a503a..4eb3686 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/dashboard/DashboardViewModelTest.kt @@ -3,9 +3,13 @@ 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.data.db.entity.SettingsEntity +import de.krisenvorrat.app.domain.model.AgeGroup +import de.krisenvorrat.app.domain.model.defaultAgeGroups +import de.krisenvorrat.app.domain.model.toJson import de.krisenvorrat.app.domain.repository.CategoryRepository import de.krisenvorrat.app.domain.repository.ItemRepository import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.app.ui.settings.SettingsViewModel import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase @@ -136,7 +140,15 @@ class DashboardViewModelTest { @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 + // AgeGroups: ERWACHSENER count=2, kcal=2000 → 4000 kcal/day → 0.5 days + val ageGroups = defaultAgeGroups().map { entry -> + if (entry.ageGroup == AgeGroup.ERWACHSENER) entry.copy(count = 2, kcalPerDay = 2000) + else entry + } + fakeSettingsRepository.setValue( + de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS, + ageGroups.toJson() + ) fakeItemRepository.emit( listOf( buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 200) @@ -151,6 +163,28 @@ class DashboardViewModelTest { assertEquals(0.5, viewModel.uiState.value.supplyRangeDays, 0.001) } + @Test + fun test_init_withEmptyAgeGroups_usesDefaultTotalKcalForRange() = runTest(testDispatcher) { + // Given – 1000g × 400 kcal/100g = 4000 kcal; alle AgeGroup-counts = 0 + // → totalDailyKcal = 0 → Fallback auf DEFAULT_TOTAL_DAILY_KCAL = 4000 + // → supplyRangeDays = 4000 / 4000 = 1.0 + val allZeroAgeGroups = defaultAgeGroups().map { it.copy(count = 0) } + fakeSettingsRepository.setValue( + de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS, + allZeroAgeGroups.toJson() + ) + fakeItemRepository.emit( + listOf(buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 400)) + ) + viewModel = createViewModel() + + // When + advanceUntilIdle() + + // Then + assertEquals(1.0, viewModel.uiState.value.supplyRangeDays, 0.001) + } + @Test fun test_init_withExpiringItems_expiryWarningsArePresent() = runTest(testDispatcher) { // Given – item expiring in 3 months (within URGENT_MONTHS=6) diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index c239817..0d6f8cf 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -9,7 +9,10 @@ import de.krisenvorrat.app.domain.model.SyncError import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SyncService -import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase +import de.krisenvorrat.app.domain.model.AgeGroup +import de.krisenvorrat.app.domain.model.AgeGroupEntry +import de.krisenvorrat.app.domain.model.defaultAgeGroups +import de.krisenvorrat.app.domain.model.toJson import de.krisenvorrat.shared.model.InventoryDto import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -74,7 +77,7 @@ class SettingsViewModelTest { ) @Test - fun test_init_withNoStoredValues_showsDefaults() = runTest(testDispatcher) { + fun test_init_withNoStoredValues_showsDefaultAgeGroups() = runTest(testDispatcher) { // Given – no stored settings viewModel = createViewModel() @@ -84,68 +87,122 @@ class SettingsViewModelTest { // Then val state = viewModel.uiState.value assertFalse(state.isLoading) - assertEquals( - CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(), - state.householdSize - ) - assertEquals( - CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(), - state.dailyKcalPerPerson - ) + assertEquals(defaultAgeGroups(), state.ageGroups) } @Test - fun test_init_withStoredValues_showsStoredValues() = runTest(testDispatcher) { + fun test_init_withStoredAgeGroups_showsStoredGroups() = runTest(testDispatcher) { // Given - fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "4" + val stored = defaultAgeGroups().map { entry -> + if (entry.ageGroup == AgeGroup.ERWACHSENER) entry.copy(count = 3, kcalPerDay = 2500) + else entry + } + fakeSettingsRepository.store[SettingsViewModel.KEY_AGE_GROUPS] = stored.toJson() + viewModel = createViewModel() + + // When + advanceUntilIdle() + + // Then + val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER } + assertEquals(3, erwachsener.count) + assertEquals(2500, erwachsener.kcalPerDay) + } + + @Test + fun test_init_withLegacyHouseholdSize_migratesErwachsener() = runTest(testDispatcher) { + // Given – legacy keys present, no age_groups key + fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3" fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON] = "2500" - viewModel = createViewModel() // When advanceUntilIdle() // Then - val state = viewModel.uiState.value - assertEquals("4", state.householdSize) - assertEquals("2500", state.dailyKcalPerPerson) + val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER } + assertEquals(3, erwachsener.count) + assertEquals(2500, erwachsener.kcalPerDay) } @Test - fun test_onHouseholdSizeChanged_updatesState() = runTest(testDispatcher) { + fun test_init_withLegacyHouseholdSizeOnly_migratesWithDefaultKcal() = runTest(testDispatcher) { + // Given – nur KEY_HOUSEHOLD_SIZE gesetzt, kein KEY_DAILY_KCAL_PER_PERSON + fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3" + viewModel = createViewModel() + + // When + advanceUntilIdle() + + // Then + val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER } + assertEquals(3, erwachsener.count) + assertEquals(AgeGroup.ERWACHSENER.defaultKcal, erwachsener.kcalPerDay) + } + + @Test + fun test_onAgeGroupCountChanged_updatesState() = runTest(testDispatcher) { // Given viewModel = createViewModel() advanceUntilIdle() // When - viewModel.onHouseholdSizeChanged("5") + viewModel.onAgeGroupCountChanged(AgeGroup.KLEINKIND, "2") // Then - assertEquals("5", viewModel.uiState.value.householdSize) + val kleinkind = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.KLEINKIND } + assertEquals(2, kleinkind.count) assertFalse(viewModel.uiState.value.isSaved) } @Test - fun test_onDailyKcalChanged_updatesState() = runTest(testDispatcher) { + fun test_onAgeGroupKcalChanged_updatesState() = runTest(testDispatcher) { // Given viewModel = createViewModel() advanceUntilIdle() // When - viewModel.onDailyKcalChanged("1800") + viewModel.onAgeGroupKcalChanged(AgeGroup.ERWACHSENER, "2200") // Then - assertEquals("1800", viewModel.uiState.value.dailyKcalPerPerson) + val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER } + assertEquals(2200, erwachsener.kcalPerDay) assertFalse(viewModel.uiState.value.isSaved) } @Test - fun test_saveSettings_withValidValues_persistsAndShowsSaved() = runTest(testDispatcher) { + fun test_onAgeGroupCountChanged_withNegativeValue_doesNotUpdateState() = runTest(testDispatcher) { + // Given + viewModel = createViewModel() + advanceUntilIdle() + val initialGroups = viewModel.uiState.value.ageGroups + + // When + viewModel.onAgeGroupCountChanged(AgeGroup.KLEINKIND, "-1") + + // Then + assertEquals(initialGroups, viewModel.uiState.value.ageGroups) + } + + @Test + fun test_onAgeGroupKcalChanged_withZeroValue_doesNotUpdateState() = runTest(testDispatcher) { + // Given + viewModel = createViewModel() + advanceUntilIdle() + val initialGroups = viewModel.uiState.value.ageGroups + + // When + viewModel.onAgeGroupKcalChanged(AgeGroup.ERWACHSENER, "0") + + // Then + assertEquals(initialGroups, viewModel.uiState.value.ageGroups) + } + + @Test + fun test_saveSettings_persistsAgeGroupsJson() = runTest(testDispatcher) { // Given viewModel = createViewModel() advanceUntilIdle() - viewModel.onHouseholdSizeChanged("3") - viewModel.onDailyKcalChanged("2200") // When viewModel.saveSettings() @@ -153,40 +210,7 @@ class SettingsViewModelTest { // Then assertTrue(viewModel.uiState.value.isSaved) - assertEquals("3", fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE]) - assertEquals("2200", fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON]) - } - - @Test - fun test_saveSettings_withInvalidHouseholdSize_doesNotPersistInvalid() = runTest(testDispatcher) { - // Given - viewModel = createViewModel() - advanceUntilIdle() - viewModel.onHouseholdSizeChanged("abc") - viewModel.onDailyKcalChanged("2200") - - // When - viewModel.saveSettings() - advanceUntilIdle() - - // Then – household_size not stored, but dailyKcal is - assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE)) - assertEquals("2200", fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON]) - } - - @Test - fun test_saveSettings_withZeroHouseholdSize_doesNotPersist() = runTest(testDispatcher) { - // Given - viewModel = createViewModel() - advanceUntilIdle() - viewModel.onHouseholdSizeChanged("0") - - // When - viewModel.saveSettings() - advanceUntilIdle() - - // Then – zero is invalid, not persisted - assertFalse(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_HOUSEHOLD_SIZE)) + assertTrue(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_AGE_GROUPS)) } @Test @@ -507,7 +531,6 @@ class SettingsViewModelTest { // Given viewModel = createViewModel() advanceUntilIdle() - viewModel.onHouseholdSizeChanged("2") viewModel.onServerUrlChanged("https://myserver.com") viewModel.onApiKeyChanged("secret-key-123")