feat(settings): implement SettingsScreen with ViewModel, UI and persistence

Implement the full Settings tab with household size and daily kcal/person
input fields, persisted via Room through the existing SettingsRepository.
The DashboardViewModel now reads settings reactively and passes them to
CalculateSupplyRangeUseCase instead of using hardcoded defaults.

Changes:
- Add observeValue(key) Flow method to SettingsDao and SettingsRepository
- Create SettingsViewModel with load/save logic and input validation
- Create SettingsUiState data class
- Replace SettingsScreen placeholder with full Compose UI
  (household size, kcal/day fields, save button, export/import placeholders)
- Integrate settings into DashboardViewModel via combine() with 4 flows
- Add SettingsViewModelTest (6 tests covering defaults, persistence, validation)
- Update DashboardViewModelTest and test fakes for new constructor parameter

Closes #35
This commit is contained in:
Jens Reinemann 2026-05-14 02:50:44 +02:00
parent 34bd1f603f
commit e85b151cd5
11 changed files with 443 additions and 15 deletions

View file

@ -15,6 +15,9 @@ internal interface SettingsDao {
@Query("SELECT value FROM settings WHERE `key` = :key")
suspend fun getValue(key: String): String?
@Query("SELECT value FROM settings WHERE `key` = :key")
fun observeValue(key: String): Flow<String?>
@Query("SELECT * FROM settings")
fun getAll(): Flow<List<SettingsEntity>>

View file

@ -18,5 +18,7 @@ internal class SettingsRepositoryImpl @Inject constructor(
override suspend fun setValue(key: String, value: String) =
withContext(Dispatchers.IO) { dao.upsert(SettingsEntity(key = key, value = value)) }
override fun observeValue(key: String): Flow<String?> = dao.observeValue(key)
override fun getAll(): Flow<List<SettingsEntity>> = dao.getAll()
}

View file

@ -6,5 +6,6 @@ import kotlinx.coroutines.flow.Flow
internal interface SettingsRepository {
suspend fun getValue(key: String): String?
suspend fun setValue(key: String, value: String)
fun observeValue(key: String): Flow<String?>
fun getAll(): Flow<List<SettingsEntity>>
}

View file

@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
import de.krisenvorrat.app.domain.usecase.GetExpiryWarningsUseCase
import de.krisenvorrat.app.domain.usecase.GetMinStockWarningsUseCase
import de.krisenvorrat.app.ui.settings.SettingsViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -22,6 +24,7 @@ import javax.inject.Inject
internal class DashboardViewModel @Inject constructor(
private val itemRepository: ItemRepository,
private val categoryRepository: CategoryRepository,
private val settingsRepository: SettingsRepository,
private val calculateCategorySummary: CalculateCategorySummaryUseCase,
private val calculateTotalValue: CalculateTotalValueUseCase,
private val calculateSupplyRange: CalculateSupplyRangeUseCase,
@ -36,12 +39,19 @@ internal class DashboardViewModel @Inject constructor(
viewModelScope.launch {
combine(
itemRepository.getAll(),
categoryRepository.getAll()
) { items, categories ->
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
DashboardUiState(
categorySummaries = calculateCategorySummary(items, categories),
totalValue = calculateTotalValue(items),
supplyRangeDays = calculateSupplyRange(items),
supplyRangeDays = calculateSupplyRange(items, householdSize, dailyKcal),
expiryWarnings = getExpiryWarnings(items),
minStockWarnings = getMinStockWarnings(items),
isLoading = false

View file

@ -1,34 +1,144 @@
package de.krisenvorrat.app.ui.settings
import androidx.compose.foundation.layout.Box
import android.widget.Toast
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SettingsScreen() {
internal fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
TopAppBar(title = { Text("Einstellungen") })
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Einstellungen werden in einem späteren Schritt implementiert.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
)
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Text(
text = "Haushalt",
style = MaterialTheme.typography.titleMedium
)
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()
)
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()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = viewModel::saveSettings,
modifier = Modifier.fillMaxWidth()
) {
Text("Speichern")
}
if (uiState.isSaved) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Einstellungen gespeichert",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(32.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Daten",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
Toast.makeText(
context,
"Export wird in einem späteren Update verfügbar.",
Toast.LENGTH_SHORT
).show()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Daten exportieren")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
Toast.makeText(
context,
"Import wird in einem späteren Update verfügbar.",
Toast.LENGTH_SHORT
).show()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Daten importieren")
}
}
}
}
}

View file

@ -0,0 +1,8 @@
package de.krisenvorrat.app.ui.settings
internal data class SettingsUiState(
val householdSize: String = "",
val dailyKcalPerPerson: String = "",
val isLoading: Boolean = true,
val isSaved: Boolean = false
)

View file

@ -0,0 +1,86 @@
package de.krisenvorrat.app.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
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 SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadSettings()
}
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()
_uiState.update {
it.copy(
householdSize = householdSize,
dailyKcalPerPerson = dailyKcal,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
householdSize = CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString(),
dailyKcalPerPerson = CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString(),
isLoading = false
)
}
}
}
}
fun onHouseholdSizeChanged(value: String) {
_uiState.update { it.copy(householdSize = value, isSaved = false) }
}
fun onDailyKcalChanged(value: String) {
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
}
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)
}
_uiState.update { it.copy(isSaved = true) }
} catch (e: Exception) {
// Fehler beim Speichern UI zeigt keinen Saved-Status
}
}
}
companion object {
const val KEY_HOUSEHOLD_SIZE = "household_size"
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
}
}

View file

@ -92,6 +92,7 @@ internal class FakeSettingsDao : SettingsDao {
override suspend fun upsert(setting: SettingsEntity) = throw UnsupportedOperationException()
override suspend fun getValue(key: String): String? = throw UnsupportedOperationException()
override fun observeValue(key: String): Flow<String?> = throw UnsupportedOperationException()
override fun getAll(): Flow<List<SettingsEntity>> = flow
override suspend fun upsertAll(settings: List<SettingsEntity>) {

View file

@ -22,6 +22,9 @@ private class FakeSettingsDao : SettingsDao {
override suspend fun getValue(key: String): String? = store[key]
override fun observeValue(key: String): Flow<String?> =
MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = flow
override suspend fun upsertAll(settings: List<SettingsEntity>) {

View file

@ -2,8 +2,10 @@ 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.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
@ -32,6 +34,7 @@ class DashboardViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeItemRepository: FakeItemRepository
private lateinit var fakeCategoryRepository: FakeCategoryRepository
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var viewModel: DashboardViewModel
@Before
@ -39,6 +42,7 @@ class DashboardViewModelTest {
Dispatchers.setMain(testDispatcher)
fakeItemRepository = FakeItemRepository()
fakeCategoryRepository = FakeCategoryRepository()
fakeSettingsRepository = FakeSettingsRepository()
}
@After
@ -49,6 +53,7 @@ class DashboardViewModelTest {
private fun createViewModel() = DashboardViewModel(
itemRepository = fakeItemRepository,
categoryRepository = fakeCategoryRepository,
settingsRepository = fakeSettingsRepository,
calculateCategorySummary = CalculateCategorySummaryUseCase(),
calculateTotalValue = CalculateTotalValueUseCase(),
calculateSupplyRange = CalculateSupplyRangeUseCase(),
@ -318,4 +323,23 @@ private class FakeCategoryRepository : CategoryRepository {
}
}
private class FakeSettingsRepository : SettingsRepository {
private val store = mutableMapOf<String, String>()
private val observeFlows = mutableMapOf<String, MutableStateFlow<String?>>()
private val allFlow = MutableStateFlow<List<SettingsEntity>>(emptyList())
override suspend fun getValue(key: String): String? = store[key]
override suspend fun setValue(key: String, value: String) {
store[key] = value
observeFlows[key]?.value = value
allFlow.value = store.map { SettingsEntity(key = it.key, value = it.value) }
}
override fun observeValue(key: String): Flow<String?> =
observeFlows.getOrPut(key) { MutableStateFlow(store[key]) }
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
}
// endregion

View file

@ -0,0 +1,180 @@
package de.krisenvorrat.app.ui.settings
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
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
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var viewModel: SettingsViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeSettingsRepository = FakeSettingsRepository()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel() = SettingsViewModel(
settingsRepository = fakeSettingsRepository
)
@Test
fun test_init_withNoStoredValues_showsDefaults() = runTest(testDispatcher) {
// Given no stored settings
viewModel = createViewModel()
// When
advanceUntilIdle()
// 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
)
}
@Test
fun test_init_withStoredValues_showsStoredValues() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "4"
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)
}
@Test
fun test_onHouseholdSizeChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onHouseholdSizeChanged("5")
// Then
assertEquals("5", viewModel.uiState.value.householdSize)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_onDailyKcalChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onDailyKcalChanged("1800")
// Then
assertEquals("1800", viewModel.uiState.value.dailyKcalPerPerson)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_saveSettings_withValidValues_persistsAndShowsSaved() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("3")
viewModel.onDailyKcalChanged("2200")
// When
viewModel.saveSettings()
advanceUntilIdle()
// 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))
}
}
// region Test Helpers
private class FakeSettingsRepository : SettingsRepository {
val store = mutableMapOf<String, String>()
private val allFlow = MutableStateFlow<List<SettingsEntity>>(emptyList())
override suspend fun getValue(key: String): String? = store[key]
override suspend fun setValue(key: String, value: String) {
store[key] = value
allFlow.value = store.map { SettingsEntity(key = it.key, value = it.value) }
}
override fun observeValue(key: String): Flow<String?> =
MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
}
// endregion