feat(settings): add sync UI with server configuration and push/pull actions

Closes #45

SettingsScreen:
- Server-URL and API-Key input fields (API-Key masked as password)
- Push (upload) and Pull (download) sync buttons
- Sync status indicator (running/success/error)
- Last sync timestamp display (persisted via Room settings)

SettingsViewModel:
- Inject SyncService for server communication
- pushSync(): exports local inventory to InventoryDto, uploads via SyncService
- pullSync(): downloads InventoryDto from server, imports into local DB
- Persists server_url, api_key, sync_last_timestamp in Room settings table

ImportExportRepository:
- New methods exportToInventoryDto() and importFromInventoryDto()
- Refactored to eliminate code duplication via buildInventoryDto() and
  applyInventoryDto() helper methods

Tests:
- 8 new ViewModel tests covering sync settings loading, push/pull success,
  push/pull error scenarios, and sync status dismissal
- FakeSyncService and extended FakeImportExportRepository for testing
This commit is contained in:
Jens Reinemann 2026-05-14 21:30:55 +02:00
parent 215790d68e
commit a972ce34ca
6 changed files with 478 additions and 24 deletions

View file

@ -37,12 +37,21 @@ internal class ImportExportRepositoryImpl @Inject constructor(
}
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) {
val exportData = buildInventoryDto()
jsonSerializer.encodeToString(InventoryDto.serializer(), exportData)
}
override suspend fun exportToInventoryDto(): InventoryDto = withContext(Dispatchers.IO) {
buildInventoryDto()
}
private suspend fun buildInventoryDto(): InventoryDto {
val categories = categoryDao.getAll().first()
val locations = locationDao.getAll().first()
val items = itemDao.getAll().first()
val settings = settingsDao.getAll().first()
val exportData = InventoryDto(
return InventoryDto(
categories = categories.map { CategoryDto(id = it.id, name = it.name) },
locations = locations.map { LocationDto(id = it.id, name = it.name) },
items = items.map { item ->
@ -63,17 +72,27 @@ internal class ImportExportRepositoryImpl @Inject constructor(
},
settings = settings.map { SettingDto(key = it.key, value = it.value) }
)
jsonSerializer.encodeToString(InventoryDto.serializer(), exportData)
}
override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val exportData = jsonSerializer.decodeFromString<InventoryDto>(json)
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
applyInventoryDto(exportData)
}
}
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
applyInventoryDto(dto)
}
}
private suspend fun applyInventoryDto(dto: InventoryDto) {
transaction.execute {
categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) })
itemDao.upsertAll(exportData.items.map { item ->
categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) })
itemDao.upsertAll(dto.items.map { item ->
ItemEntity(
id = item.id,
name = item.name,
@ -89,8 +108,7 @@ internal class ImportExportRepositoryImpl @Inject constructor(
lastUpdated = item.lastUpdated
)
})
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) })
}
settingsDao.upsertAll(dto.settings.map { SettingsEntity(key = it.key, value = it.value) })
}
}

View file

@ -1,7 +1,11 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto
internal interface ImportExportRepository {
suspend fun exportToJson(): String
suspend fun importFromJson(json: String): Result<Unit>
suspend fun exportToMarkdown(): String
suspend fun exportToInventoryDto(): InventoryDto
suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit>
}

View file

@ -4,12 +4,16 @@ import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@ -27,9 +31,11 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -184,6 +190,109 @@ internal fun SettingsScreen(
modifier = Modifier.padding(8.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Server-Synchronisierung",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = viewModel::onServerUrlChanged,
label = { Text("Server-URL") },
placeholder = { Text("https://example.com") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = uiState.apiKey,
onValueChange = viewModel::onApiKeyChanged,
label = { Text("API-Key") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
val isSyncEnabled = uiState.serverUrl.isNotBlank() &&
uiState.apiKey.isNotBlank() &&
uiState.syncStatus !is SyncStatus.Running
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = viewModel::pushSync,
enabled = isSyncEnabled,
modifier = Modifier.weight(1f)
) {
Text("Push (Hochladen)")
}
OutlinedButton(
onClick = viewModel::pullSync,
enabled = isSyncEnabled,
modifier = Modifier.weight(1f)
) {
Text("Pull (Herunterladen)")
}
}
when (val syncStatus = uiState.syncStatus) {
is SyncStatus.Running -> {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Synchronisierung läuft…",
style = MaterialTheme.typography.bodySmall
)
}
}
is SyncStatus.Success -> {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Synchronisierung erfolgreich",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
is SyncStatus.Error -> {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = syncStatus.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
is SyncStatus.Idle -> { /* no indicator */ }
}
if (uiState.lastSyncTime != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Letzte Synchronisierung: ${uiState.lastSyncTime}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View file

@ -12,9 +12,20 @@ internal data class SettingsUiState(
val exportError: String? = null,
val isImporting: Boolean = false,
val importResult: ImportResult? = null,
val pendingImportUri: Uri? = null
val pendingImportUri: Uri? = null,
val serverUrl: String = "",
val apiKey: String = "",
val syncStatus: SyncStatus = SyncStatus.Idle,
val lastSyncTime: String? = null
)
internal sealed interface SyncStatus {
data object Idle : SyncStatus
data object Running : SyncStatus
data object Success : SyncStatus
data class Error(val message: String) : SyncStatus
}
internal sealed interface ImportResult {
data object Success : ImportResult
data class Error(val message: String) : ImportResult

View file

@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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
@ -16,12 +17,17 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
internal class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository,
private val syncService: SyncService,
@ApplicationContext private val context: Context
) : ViewModel() {
@ -39,11 +45,17 @@ internal class SettingsViewModel @Inject constructor(
?: 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 apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
_uiState.update {
it.copy(
householdSize = householdSize,
dailyKcalPerPerson = dailyKcal,
serverUrl = serverUrl,
apiKey = apiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
isLoading = false
)
}
@ -67,6 +79,14 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
}
fun onServerUrlChanged(value: String) {
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
}
fun onApiKeyChanged(value: String) {
_uiState.update { it.copy(apiKey = value, isSaved = false) }
}
fun saveSettings() {
viewModelScope.launch {
try {
@ -80,6 +100,9 @@ internal class SettingsViewModel @Inject constructor(
settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal)
}
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
_uiState.update { it.copy(isSaved = true) }
} catch (e: Exception) {
// Fehler beim Speichern UI zeigt keinen Saved-Status
@ -166,8 +189,93 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(importResult = null) }
}
fun pushSync() {
viewModelScope.launch {
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
try {
val inventoryDto = importExportRepository.exportToInventoryDto()
val result = syncService.uploadInventory(inventoryDto)
result.fold(
onSuccess = {
val now = Instant.now().toEpochMilli().toString()
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
_uiState.update {
it.copy(
syncStatus = SyncStatus.Success,
lastSyncTime = formatTimestamp(now)
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen"))
}
}
)
} catch (e: Exception) {
_uiState.update {
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen"))
}
}
}
}
fun pullSync() {
viewModelScope.launch {
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
try {
val result = syncService.downloadInventory()
result.fold(
onSuccess = { inventoryDto ->
importExportRepository.importFromInventoryDto(inventoryDto).fold(
onSuccess = {
val now = Instant.now().toEpochMilli().toString()
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
loadSettings()
_uiState.update {
it.copy(
syncStatus = SyncStatus.Success,
lastSyncTime = formatTimestamp(now)
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Import fehlgeschlagen"))
}
}
)
},
onFailure = { e ->
_uiState.update {
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen"))
}
}
)
} catch (e: Exception) {
_uiState.update {
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen"))
}
}
}
}
fun onSyncStatusDismissed() {
_uiState.update { it.copy(syncStatus = SyncStatus.Idle) }
}
private fun formatTimestamp(epochMillisStr: String): String? {
val millis = epochMillisStr.toLongOrNull() ?: return null
val instant = Instant.ofEpochMilli(millis)
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMAN)
return formatter.format(instant.atZone(ZoneId.systemDefault()))
}
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"
}
}

View file

@ -5,9 +5,12 @@ import androidx.core.content.FileProvider
import android.content.ContentResolver
import android.net.Uri
import de.krisenvorrat.app.data.db.entity.SettingsEntity
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.shared.model.InventoryDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -38,6 +41,7 @@ class SettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeImportExportRepository: FakeImportExportRepository
private lateinit var fakeSyncService: FakeSyncService
private lateinit var mockContext: Context
private lateinit var tempDir: File
private lateinit var viewModel: SettingsViewModel
@ -47,6 +51,7 @@ class SettingsViewModelTest {
Dispatchers.setMain(testDispatcher)
fakeSettingsRepository = FakeSettingsRepository()
fakeImportExportRepository = FakeImportExportRepository()
fakeSyncService = FakeSyncService()
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
tempDir.mkdirs()
mockContext = mockk(relaxed = true) {
@ -64,6 +69,7 @@ class SettingsViewModelTest {
private fun createViewModel() = SettingsViewModel(
settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository,
syncService = fakeSyncService,
context = mockContext
)
@ -446,6 +452,159 @@ class SettingsViewModelTest {
// Then
assertNull(viewModel.uiState.value.importResult)
}
// --- Sync Tests ---
@Test
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com"
fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY] = "my-secret-key"
fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
viewModel = createViewModel()
// When
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertEquals("https://example.com", state.serverUrl)
assertEquals("my-secret-key", state.apiKey)
assertNotNull(state.lastSyncTime)
}
@Test
fun test_onServerUrlChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onServerUrlChanged("https://new-server.com")
// Then
assertEquals("https://new-server.com", viewModel.uiState.value.serverUrl)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onApiKeyChanged("new-api-key")
// Then
assertEquals("new-api-key", viewModel.uiState.value.apiKey)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun test_saveSettings_persistsServerUrlAndApiKey() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onHouseholdSizeChanged("2")
viewModel.onServerUrlChanged("https://myserver.com")
viewModel.onApiKeyChanged("secret-key-123")
// When
viewModel.saveSettings()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY])
}
@Test
fun test_pushSync_success_updatesSyncStatusAndTimestamp() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.pushSync()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Success)
assertNotNull(state.lastSyncTime)
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
}
@Test
fun test_pushSync_failure_setsSyncStatusError() = runTest(testDispatcher) {
// Given
fakeSyncService.uploadShouldFail = true
fakeSyncService.uploadError = SyncError.ConnectionError()
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.pushSync()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Error)
assertTrue((state.syncStatus as SyncStatus.Error).message.contains("Server nicht erreichbar"))
}
@Test
fun test_pullSync_success_updatesSyncStatusAndTimestamp() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.pullSync()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Success)
assertNotNull(state.lastSyncTime)
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
}
@Test
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
// Given
fakeSyncService.downloadShouldFail = true
fakeSyncService.downloadError = SyncError.AuthError()
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.pullSync()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Error)
assertTrue((state.syncStatus as SyncStatus.Error).message.contains("Authentifizierung"))
}
@Test
fun test_onSyncStatusDismissed_resetsSyncStatus() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.pushSync()
advanceUntilIdle()
// When
viewModel.onSyncStatusDismissed()
// Then
assertTrue(viewModel.uiState.value.syncStatus is SyncStatus.Idle)
}
}
// region Test Helpers
@ -472,6 +631,12 @@ private class FakeImportExportRepository : ImportExportRepository {
var markdownResult = "# Krisenvorrat Inventar\n"
var shouldThrow = false
var importShouldFail = false
var inventoryDto = InventoryDto(
categories = emptyList(),
locations = emptyList(),
items = emptyList(),
settings = emptyList()
)
override suspend fun exportToJson(): String {
if (shouldThrow) throw RuntimeException("Test error")
@ -487,6 +652,45 @@ private class FakeImportExportRepository : ImportExportRepository {
if (shouldThrow) throw RuntimeException("Test error")
return markdownResult
}
override suspend fun exportToInventoryDto(): InventoryDto {
if (shouldThrow) throw RuntimeException("Test error")
return inventoryDto
}
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> {
if (shouldThrow || importShouldFail) return Result.failure(RuntimeException("Test error"))
return Result.success(Unit)
}
}
private class FakeSyncService : SyncService {
var uploadShouldFail = false
var downloadShouldFail = false
var uploadError: Exception = RuntimeException("Upload failed")
var downloadError: Exception = RuntimeException("Download failed")
var downloadResult = InventoryDto(
categories = emptyList(),
locations = emptyList(),
items = emptyList(),
settings = emptyList()
)
var uploadResult = InventoryDto(
categories = emptyList(),
locations = emptyList(),
items = emptyList(),
settings = emptyList()
)
override suspend fun downloadInventory(): Result<InventoryDto> {
if (downloadShouldFail) return Result.failure(downloadError)
return Result.success(downloadResult)
}
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> {
if (uploadShouldFail) return Result.failure(uploadError)
return Result.success(uploadResult)
}
}
// endregion