feat(settings): Kinder-Altersgruppen für Kalorienverbrauch (#47)
domain/model/AgeGroup.kt: - Neues Enum AgeGroup (6 Gruppen: Kleinkind bis Erwachsener) mit Richtwert-kcal - AgeGroupEntry-Datenklasse (count + kcalPerDay, anpassbar) - JSON-Serialisierung via kotlinx.serialization - Migrationspfad: householdSize + kcalPerPerson → ERWACHSENER-Gruppe domain/model/SettingsKeys.kt: - Neue Konstanten-Klasse für Settings-Keys (löst SettingsViewModel-Kopplung im DashboardViewModel auf) CalculateSupplyRangeUseCase: Signatur auf totalDailyKcal vereinfacht SettingsViewModel/UiState: ageGroups statt householdSize + dailyKcalPerPerson DashboardViewModel: combine(3 flows), AGE_GROUPS statt 4 separate Flows SettingsScreen: Altersgruppen-Tabelle statt zwei TextFields ImportExportRepositoryImpl: Markdown-Export zeigt Altersgruppen-Gesamtkcal Tests: +8 neue Tests (Migration, Validierung, Fallbacks), alle bestehenden Tests aktualisiert Closes #47
This commit is contained in:
parent
1236d61543
commit
9cc15ffaad
13 changed files with 391 additions and 153 deletions
|
|
@ -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.ItemEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
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.app.domain.repository.ImportExportRepository
|
||||||
import de.krisenvorrat.shared.model.CategoryDto
|
import de.krisenvorrat.shared.model.CategoryDto
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
|
@ -143,15 +145,16 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val settingsMap = settings.associate { it.key to it.value }
|
val settingsMap = settings.associate { it.key to it.value }
|
||||||
val householdSize = settingsMap["household_size"]
|
val ageGroupsJson = settingsMap["age_groups"]
|
||||||
val kcalPerDay = settingsMap["kcal_per_day"]
|
if (ageGroupsJson != null) {
|
||||||
if (householdSize != null || kcalPerDay != null) {
|
val totalDailyKcal = parseAgeGroupsFromJson(ageGroupsJson).totalDailyKcal()
|
||||||
|
if (totalDailyKcal > 0) {
|
||||||
sb.appendLine("## Einstellungen")
|
sb.appendLine("## Einstellungen")
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
if (householdSize != null) sb.appendLine("- **Haushaltsgröße:** $householdSize Personen")
|
sb.appendLine("- **Haushalt kcal/Tag:** $totalDailyKcal")
|
||||||
if (kcalPerDay != null) sb.appendLine("- **kcal/Tag:** $kcalPerDay")
|
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sb.toString().trimEnd() + "\n"
|
sb.toString().trimEnd() + "\n"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<AgeGroupEntry>.totalDailyKcal(): Int = sumOf { it.count * it.kcalPerDay }
|
||||||
|
|
||||||
|
internal fun defaultAgeGroups(): List<AgeGroupEntry> = 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<AgeGroupEntry>.toJson(): String = ageGroupsJson.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()),
|
||||||
|
this
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseAgeGroupsFromJson(json: String?): List<AgeGroupEntry> {
|
||||||
|
if (json.isNullOrBlank()) return defaultAgeGroups()
|
||||||
|
return try {
|
||||||
|
ageGroupsJson.decodeFromString(
|
||||||
|
kotlinx.serialization.builtins.ListSerializer(AgeGroupEntry.serializer()),
|
||||||
|
json
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
defaultAgeGroups()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,14 @@ internal class CalculateSupplyRangeUseCase @Inject constructor() {
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_HOUSEHOLD_SIZE = 2
|
const val DEFAULT_HOUSEHOLD_SIZE = 2
|
||||||
const val DEFAULT_DAILY_KCAL_PER_PERSON = 2000
|
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(
|
operator fun invoke(
|
||||||
items: List<ItemEntity>,
|
items: List<ItemEntity>,
|
||||||
householdSize: Int = DEFAULT_HOUSEHOLD_SIZE,
|
totalDailyKcal: Int = DEFAULT_TOTAL_DAILY_KCAL
|
||||||
dailyKcalPerPerson: Int = DEFAULT_DAILY_KCAL_PER_PERSON
|
|
||||||
): Double {
|
): Double {
|
||||||
val dailyNeed = householdSize * dailyKcalPerPerson
|
val dailyNeed = totalDailyKcal
|
||||||
if (dailyNeed <= 0) return 0.0
|
if (dailyNeed <= 0) return 0.0
|
||||||
|
|
||||||
val totalKcal = items.sumOf { item ->
|
val totalKcal = items.sumOf { item ->
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ package de.krisenvorrat.app.ui.dashboard
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.ItemRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
|
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.CalculateTotalValueUseCase
|
||||||
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
|
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
|
||||||
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
|
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
|
||||||
import de.krisenvorrat.app.ui.settings.SettingsViewModel
|
import de.krisenvorrat.app.domain.repository.CategoryRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -40,18 +42,17 @@ internal class DashboardViewModel @Inject constructor(
|
||||||
combine(
|
combine(
|
||||||
itemRepository.getAll(),
|
itemRepository.getAll(),
|
||||||
categoryRepository.getAll(),
|
categoryRepository.getAll(),
|
||||||
settingsRepository.observeValue(SettingsViewModel.KEY_HOUSEHOLD_SIZE),
|
settingsRepository.observeValue(SettingsKeys.AGE_GROUPS)
|
||||||
settingsRepository.observeValue(SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON)
|
) { items, categories, ageGroupsJson ->
|
||||||
) { items, categories, householdSizeStr, dailyKcalStr ->
|
val ageGroups = parseAgeGroupsFromJson(ageGroupsJson)
|
||||||
val householdSize = householdSizeStr?.toIntOrNull()
|
val totalDailyKcal = ageGroups.totalDailyKcal().let {
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE
|
if (it <= 0) CalculateSupplyRangeUseCase.DEFAULT_TOTAL_DAILY_KCAL else it
|
||||||
val dailyKcal = dailyKcalStr?.toIntOrNull()
|
}
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON
|
|
||||||
|
|
||||||
DashboardUiState(
|
DashboardUiState(
|
||||||
categorySummaries = calculateCategorySummary(items, categories),
|
categorySummaries = calculateCategorySummary(items, categories),
|
||||||
totalValue = calculateTotalValue(items),
|
totalValue = calculateTotalValue(items),
|
||||||
supplyRangeDays = calculateSupplyRange(items, householdSize, dailyKcal),
|
supplyRangeDays = calculateSupplyRange(items, totalDailyKcal),
|
||||||
expiryWarnings = getExpiryWarnings(items),
|
expiryWarnings = getExpiryWarnings(items),
|
||||||
minStockWarnings = getMinStockWarnings(items),
|
minStockWarnings = getMinStockWarnings(items),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.krisenvorrat.app.domain.model.AgeGroup
|
||||||
|
import de.krisenvorrat.app.domain.model.totalDailyKcal
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -105,25 +107,45 @@ internal fun SettingsScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
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(
|
OutlinedTextField(
|
||||||
value = uiState.householdSize,
|
value = entry.count.toString(),
|
||||||
onValueChange = viewModel::onHouseholdSizeChanged,
|
onValueChange = { viewModel.onAgeGroupCountChanged(entry.ageGroup, it) },
|
||||||
label = { Text("Personenzahl") },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.width(56.dp)
|
||||||
)
|
)
|
||||||
|
Text("×", style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.dailyKcalPerPerson,
|
value = entry.kcalPerDay.toString(),
|
||||||
onValueChange = viewModel::onDailyKcalChanged,
|
onValueChange = { viewModel.onAgeGroupKcalChanged(entry.ageGroup, it) },
|
||||||
label = { Text("kcal pro Tag pro Person") },
|
label = { Text("kcal/Tag") },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.width(104.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package de.krisenvorrat.app.ui.settings
|
package de.krisenvorrat.app.ui.settings
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import de.krisenvorrat.app.domain.model.AgeGroupEntry
|
||||||
|
import de.krisenvorrat.app.domain.model.defaultAgeGroups
|
||||||
|
|
||||||
internal data class SettingsUiState(
|
internal data class SettingsUiState(
|
||||||
val householdSize: String = "",
|
val ageGroups: List<AgeGroupEntry> = defaultAgeGroups(),
|
||||||
val dailyKcalPerPerson: String = "",
|
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val isExporting: Boolean = false,
|
val isExporting: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,15 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.ImportExportRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SyncService
|
import de.krisenvorrat.app.domain.repository.SyncService
|
||||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -41,18 +46,14 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
private fun loadSettings() {
|
private fun loadSettings() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val householdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE)
|
val ageGroups = loadAgeGroupsWithMigration()
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
|
|
||||||
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
|
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString()
|
|
||||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
|
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
|
||||||
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
|
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
|
||||||
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
householdSize = householdSize,
|
ageGroups = ageGroups,
|
||||||
dailyKcalPerPerson = dailyKcal,
|
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
apiKey = apiKey,
|
apiKey = apiKey,
|
||||||
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
||||||
|
|
@ -62,8 +63,7 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
householdSize = CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(),
|
ageGroups = defaultAgeGroups(),
|
||||||
dailyKcalPerPerson = CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(),
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -71,12 +71,48 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onHouseholdSizeChanged(value: String) {
|
private suspend fun loadAgeGroupsWithMigration(): List<AgeGroupEntry> {
|
||||||
_uiState.update { it.copy(householdSize = value, isSaved = false) }
|
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) {
|
fun onAgeGroupCountChanged(ageGroup: AgeGroup, count: String) {
|
||||||
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
|
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) {
|
fun onServerUrlChanged(value: String) {
|
||||||
|
|
@ -90,16 +126,7 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
fun saveSettings() {
|
fun saveSettings() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val householdSize = _uiState.value.householdSize
|
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
|
||||||
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_SERVER_URL, _uiState.value.serverUrl)
|
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
|
||||||
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
|
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
|
||||||
|
|
||||||
|
|
@ -272,10 +299,11 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KEY_HOUSEHOLD_SIZE = "household_size"
|
val KEY_HOUSEHOLD_SIZE = SettingsKeys.HOUSEHOLD_SIZE
|
||||||
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
|
||||||
const val KEY_SERVER_URL = "server_url"
|
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
|
||||||
const val KEY_API_KEY = "api_key"
|
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||||
const val KEY_SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
val KEY_API_KEY = SettingsKeys.API_KEY
|
||||||
|
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.CategoryEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
import de.krisenvorrat.app.data.db.entity.LocationEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
|
|
@ -188,9 +191,11 @@ class ImportExportRepositoryImplTest {
|
||||||
val categoryDao = FakeCategoryDao()
|
val categoryDao = FakeCategoryDao()
|
||||||
val settingsDao = FakeSettingsDao()
|
val settingsDao = FakeSettingsDao()
|
||||||
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
|
categoryDao.upsertAll(listOf(CategoryEntity(id = 1, name = "Test")))
|
||||||
|
val ageGroups = listOf(
|
||||||
|
AgeGroupEntry(ageGroup = AgeGroup.ERWACHSENER, count = 4, kcalPerDay = 2000)
|
||||||
|
)
|
||||||
settingsDao.upsertAll(listOf(
|
settingsDao.upsertAll(listOf(
|
||||||
SettingsEntity(key = "household_size", value = "4"),
|
SettingsEntity(key = "age_groups", value = ageGroups.toJson())
|
||||||
SettingsEntity(key = "kcal_per_day", value = "2000")
|
|
||||||
))
|
))
|
||||||
val repository = buildRepository(categoryDao, settingsDao = settingsDao)
|
val repository = buildRepository(categoryDao, settingsDao = settingsDao)
|
||||||
|
|
||||||
|
|
@ -199,8 +204,7 @@ class ImportExportRepositoryImplTest {
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertTrue(markdown.contains("## Einstellungen"))
|
assertTrue(markdown.contains("## Einstellungen"))
|
||||||
assertTrue(markdown.contains("**Haushaltsgröße:** 4 Personen"))
|
assertTrue(markdown.contains("**Haushalt kcal/Tag:** 8000"))
|
||||||
assertTrue(markdown.contains("**kcal/Tag:** 2000"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,13 +10,13 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withKgItems_returnsCorrectDays() {
|
fun test_invoke_withKgItems_returnsCorrectDays() {
|
||||||
// Given – 2 kg Reis à 350 kcal/100g = 7000 kcal
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 2.0, unit = "kg", kcalPer100g = 350)
|
buildTestItem(id = "1", quantity = 2.0, unit = "kg", kcalPer100g = 350)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 4000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(1.75, result, 0.001)
|
assertEquals(1.75, result, 0.001)
|
||||||
|
|
@ -25,13 +25,13 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withGramItems_returnsCorrectDays() {
|
fun test_invoke_withGramItems_returnsCorrectDays() {
|
||||||
// Given – 500 g Nudeln à 360 kcal/100g = 1800 kcal
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 500.0, unit = "g", kcalPer100g = 360)
|
buildTestItem(id = "1", quantity = 500.0, unit = "g", kcalPer100g = 360)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 2000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(0.9, result, 0.001)
|
assertEquals(0.9, result, 0.001)
|
||||||
|
|
@ -40,14 +40,14 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withMultipleItems_sumsTotalKcal() {
|
fun test_invoke_withMultipleItems_sumsTotalKcal() {
|
||||||
// Given – 1 kg Reis (350 kcal/100g = 3500 kcal) + 500 g Nudeln (360 kcal/100g = 1800 kcal) = 5300 kcal
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
|
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
|
||||||
buildTestItem(id = "2", quantity = 500.0, unit = "g", kcalPer100g = 360)
|
buildTestItem(id = "2", quantity = 500.0, unit = "g", kcalPer100g = 360)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 4000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(1.325, result, 0.001)
|
assertEquals(1.325, result, 0.001)
|
||||||
|
|
@ -56,7 +56,7 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withEmptyList_returnsZero() {
|
fun test_invoke_withEmptyList_returnsZero() {
|
||||||
// Given / When
|
// Given / When
|
||||||
val result = useCase(emptyList(), householdSize = 2, dailyKcalPerPerson = 2000)
|
val result = useCase(emptyList(), totalDailyKcal = 4000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(0.0, result, 0.001)
|
assertEquals(0.0, result, 0.001)
|
||||||
|
|
@ -70,8 +70,8 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
buildTestItem(id = "2", quantity = 1.0, unit = "kg", kcalPer100g = 200)
|
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
|
// When – Nur 1 kg à 200 kcal/100g = 2000 kcal, 2000 kcal/Tag = 1.0 Tage
|
||||||
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 2000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(1.0, result, 0.001)
|
assertEquals(1.0, result, 0.001)
|
||||||
|
|
@ -85,30 +85,30 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 2, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 4000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(0.0, result, 0.001)
|
assertEquals(0.0, result, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withZeroHouseholdSize_returnsZero() {
|
fun test_invoke_withZeroTotalDailyKcal_returnsZero() {
|
||||||
// Given
|
// Given
|
||||||
val items = listOf(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350)
|
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 0, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 0)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(0.0, result, 0.001)
|
assertEquals(0.0, result, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withDefaultParameters_uses2PersonsAnd2000Kcal() {
|
fun test_invoke_withDefaultParameters_uses4000KcalPerDay() {
|
||||||
// Given – 4 kg Reis à 350 kcal/100g = 14000 kcal
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 4.0, unit = "kg", kcalPer100g = 350)
|
buildTestItem(id = "1", quantity = 4.0, unit = "kg", kcalPer100g = 350)
|
||||||
)
|
)
|
||||||
|
|
@ -123,13 +123,13 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withMgUnit_convertsCorrectly() {
|
fun test_invoke_withMgUnit_convertsCorrectly() {
|
||||||
// Given – 500000 mg = 500 g à 200 kcal/100g = 1000 kcal
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 500000.0, unit = "mg", kcalPer100g = 200)
|
buildTestItem(id = "1", quantity = 500000.0, unit = "mg", kcalPer100g = 200)
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 1000)
|
val result = useCase(items, totalDailyKcal = 1000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(1.0, result, 0.001)
|
assertEquals(1.0, result, 0.001)
|
||||||
|
|
@ -138,7 +138,7 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_invoke_withMixedUnits_onlyCountsWeightBased() {
|
fun test_invoke_withMixedUnits_onlyCountsWeightBased() {
|
||||||
// Given – 1 kg (350 kcal/100g) + 5 Stk (ignored) + 2 L (ignored)
|
// 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(
|
val items = listOf(
|
||||||
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
|
buildTestItem(id = "1", quantity = 1.0, unit = "kg", kcalPer100g = 350),
|
||||||
buildTestItem(id = "2", quantity = 5.0, unit = "Stk", kcalPer100g = 100),
|
buildTestItem(id = "2", quantity = 5.0, unit = "Stk", kcalPer100g = 100),
|
||||||
|
|
@ -146,7 +146,7 @@ class CalculateSupplyRangeUseCaseTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = useCase(items, householdSize = 1, dailyKcalPerPerson = 2000)
|
val result = useCase(items, totalDailyKcal = 2000)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals(1.75, result, 0.001)
|
assertEquals(1.75, result, 0.001)
|
||||||
|
|
|
||||||
|
|
@ -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.CategoryEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
import de.krisenvorrat.app.data.db.entity.ItemEntity
|
||||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
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.CategoryRepository
|
||||||
import de.krisenvorrat.app.domain.repository.ItemRepository
|
import de.krisenvorrat.app.domain.repository.ItemRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
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.CalculateCategorySummaryUseCase
|
||||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||||
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
|
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
|
||||||
|
|
@ -136,7 +140,15 @@ class DashboardViewModelTest {
|
||||||
@Test
|
@Test
|
||||||
fun test_init_withKcalItems_supplyRangeIsCalculated() = runTest(testDispatcher) {
|
fun test_init_withKcalItems_supplyRangeIsCalculated() = runTest(testDispatcher) {
|
||||||
// Given – 1kg item with 200 kcal/100g = 2000 kcal total
|
// 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(
|
fakeItemRepository.emit(
|
||||||
listOf(
|
listOf(
|
||||||
buildTestItem(id = "a", quantity = 1000.0, unit = "g", kcalPer100g = 200)
|
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)
|
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
|
@Test
|
||||||
fun test_init_withExpiringItems_expiryWarningsArePresent() = runTest(testDispatcher) {
|
fun test_init_withExpiringItems_expiryWarningsArePresent() = runTest(testDispatcher) {
|
||||||
// Given – item expiring in 3 months (within URGENT_MONTHS=6)
|
// Given – item expiring in 3 months (within URGENT_MONTHS=6)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ import de.krisenvorrat.app.domain.model.SyncError
|
||||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SyncService
|
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 de.krisenvorrat.shared.model.InventoryDto
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -74,7 +77,7 @@ class SettingsViewModelTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_init_withNoStoredValues_showsDefaults() = runTest(testDispatcher) {
|
fun test_init_withNoStoredValues_showsDefaultAgeGroups() = runTest(testDispatcher) {
|
||||||
// Given – no stored settings
|
// Given – no stored settings
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
|
|
||||||
|
|
@ -84,68 +87,122 @@ class SettingsViewModelTest {
|
||||||
// Then
|
// Then
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertFalse(state.isLoading)
|
assertFalse(state.isLoading)
|
||||||
assertEquals(
|
assertEquals(defaultAgeGroups(), state.ageGroups)
|
||||||
CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(),
|
|
||||||
state.householdSize
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(),
|
|
||||||
state.dailyKcalPerPerson
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_init_withStoredValues_showsStoredValues() = runTest(testDispatcher) {
|
fun test_init_withStoredAgeGroups_showsStoredGroups() = runTest(testDispatcher) {
|
||||||
// Given
|
// 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"
|
fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON] = "2500"
|
||||||
|
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
val state = viewModel.uiState.value
|
val erwachsener = viewModel.uiState.value.ageGroups.first { it.ageGroup == AgeGroup.ERWACHSENER }
|
||||||
assertEquals("4", state.householdSize)
|
assertEquals(3, erwachsener.count)
|
||||||
assertEquals("2500", state.dailyKcalPerPerson)
|
assertEquals(2500, erwachsener.kcalPerDay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
viewModel.onHouseholdSizeChanged("5")
|
viewModel.onAgeGroupCountChanged(AgeGroup.KLEINKIND, "2")
|
||||||
|
|
||||||
// Then
|
// 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)
|
assertFalse(viewModel.uiState.value.isSaved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_onDailyKcalChanged_updatesState() = runTest(testDispatcher) {
|
fun test_onAgeGroupKcalChanged_updatesState() = runTest(testDispatcher) {
|
||||||
// Given
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
viewModel.onDailyKcalChanged("1800")
|
viewModel.onAgeGroupKcalChanged(AgeGroup.ERWACHSENER, "2200")
|
||||||
|
|
||||||
// Then
|
// 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)
|
assertFalse(viewModel.uiState.value.isSaved)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.onHouseholdSizeChanged("3")
|
|
||||||
viewModel.onDailyKcalChanged("2200")
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
viewModel.saveSettings()
|
viewModel.saveSettings()
|
||||||
|
|
@ -153,40 +210,7 @@ class SettingsViewModelTest {
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertTrue(viewModel.uiState.value.isSaved)
|
assertTrue(viewModel.uiState.value.isSaved)
|
||||||
assertEquals("3", fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE])
|
assertTrue(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_AGE_GROUPS))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -507,7 +531,6 @@ class SettingsViewModelTest {
|
||||||
// Given
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.onHouseholdSizeChanged("2")
|
|
||||||
viewModel.onServerUrlChanged("https://myserver.com")
|
viewModel.onServerUrlChanged("https://myserver.com")
|
||||||
viewModel.onApiKeyChanged("secret-key-123")
|
viewModel.onApiKeyChanged("secret-key-123")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue