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:
parent
215790d68e
commit
a972ce34ca
6 changed files with 478 additions and 24 deletions
|
|
@ -37,12 +37,21 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) {
|
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 categories = categoryDao.getAll().first()
|
||||||
val locations = locationDao.getAll().first()
|
val locations = locationDao.getAll().first()
|
||||||
val items = itemDao.getAll().first()
|
val items = itemDao.getAll().first()
|
||||||
val settings = settingsDao.getAll().first()
|
val settings = settingsDao.getAll().first()
|
||||||
|
|
||||||
val exportData = InventoryDto(
|
return InventoryDto(
|
||||||
categories = categories.map { CategoryDto(id = it.id, name = it.name) },
|
categories = categories.map { CategoryDto(id = it.id, name = it.name) },
|
||||||
locations = locations.map { LocationDto(id = it.id, name = it.name) },
|
locations = locations.map { LocationDto(id = it.id, name = it.name) },
|
||||||
items = items.map { item ->
|
items = items.map { item ->
|
||||||
|
|
@ -63,34 +72,43 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
||||||
},
|
},
|
||||||
settings = settings.map { SettingDto(key = it.key, value = it.value) }
|
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) {
|
override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val exportData = jsonSerializer.decodeFromString<InventoryDto>(json)
|
val exportData = jsonSerializer.decodeFromString<InventoryDto>(json)
|
||||||
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
|
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
|
||||||
transaction.execute {
|
applyInventoryDto(exportData)
|
||||||
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 ->
|
|
||||||
ItemEntity(
|
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
id = item.id,
|
runCatching {
|
||||||
name = item.name,
|
applyInventoryDto(dto)
|
||||||
categoryId = item.categoryId,
|
}
|
||||||
quantity = item.quantity,
|
}
|
||||||
unit = item.unit,
|
|
||||||
unitPrice = item.unitPrice,
|
private suspend fun applyInventoryDto(dto: InventoryDto) {
|
||||||
kcalPer100g = item.kcalPer100g,
|
transaction.execute {
|
||||||
expiryDate = item.expiryDate?.let { LocalDate.parse(it) },
|
categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
|
||||||
locationId = item.locationId,
|
locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) })
|
||||||
minStock = item.minStock,
|
itemDao.upsertAll(dto.items.map { item ->
|
||||||
notes = item.notes,
|
ItemEntity(
|
||||||
lastUpdated = item.lastUpdated
|
id = item.id,
|
||||||
)
|
name = item.name,
|
||||||
})
|
categoryId = item.categoryId,
|
||||||
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) })
|
quantity = item.quantity,
|
||||||
}
|
unit = item.unit,
|
||||||
|
unitPrice = item.unitPrice,
|
||||||
|
kcalPer100g = item.kcalPer100g,
|
||||||
|
expiryDate = item.expiryDate?.let { LocalDate.parse(it) },
|
||||||
|
locationId = item.locationId,
|
||||||
|
minStock = item.minStock,
|
||||||
|
notes = item.notes,
|
||||||
|
lastUpdated = item.lastUpdated
|
||||||
|
)
|
||||||
|
})
|
||||||
|
settingsDao.upsertAll(dto.settings.map { SettingsEntity(key = it.key, value = it.value) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package de.krisenvorrat.app.domain.repository
|
package de.krisenvorrat.app.domain.repository
|
||||||
|
|
||||||
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
|
||||||
internal interface ImportExportRepository {
|
internal interface ImportExportRepository {
|
||||||
suspend fun exportToJson(): String
|
suspend fun exportToJson(): String
|
||||||
suspend fun importFromJson(json: String): Result<Unit>
|
suspend fun importFromJson(json: String): Result<Unit>
|
||||||
suspend fun exportToMarkdown(): String
|
suspend fun exportToMarkdown(): String
|
||||||
|
suspend fun exportToInventoryDto(): InventoryDto
|
||||||
|
suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
@ -27,9 +31,11 @@ import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
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
|
||||||
|
|
@ -184,6 +190,109 @@ internal fun SettingsScreen(
|
||||||
modifier = Modifier.padding(8.dp)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,20 @@ internal data class SettingsUiState(
|
||||||
val exportError: String? = null,
|
val exportError: String? = null,
|
||||||
val isImporting: Boolean = false,
|
val isImporting: Boolean = false,
|
||||||
val importResult: ImportResult? = null,
|
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 {
|
internal sealed interface ImportResult {
|
||||||
data object Success : ImportResult
|
data object Success : ImportResult
|
||||||
data class Error(val message: String) : ImportResult
|
data class Error(val message: String) : ImportResult
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
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.usecase.CalculateSupplyRangeUseCase
|
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
|
||||||
|
|
@ -16,12 +17,17 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class SettingsViewModel @Inject constructor(
|
internal class SettingsViewModel @Inject constructor(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val importExportRepository: ImportExportRepository,
|
private val importExportRepository: ImportExportRepository,
|
||||||
|
private val syncService: SyncService,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -39,11 +45,17 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
|
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
|
||||||
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
|
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
|
||||||
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString()
|
?: 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 {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
householdSize = householdSize,
|
householdSize = householdSize,
|
||||||
dailyKcalPerPerson = dailyKcal,
|
dailyKcalPerPerson = dailyKcal,
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
apiKey = apiKey,
|
||||||
|
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +79,14 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
|
_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() {
|
fun saveSettings() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -80,6 +100,9 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal)
|
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) }
|
_uiState.update { it.copy(isSaved = true) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fehler beim Speichern – UI zeigt keinen Saved-Status
|
// Fehler beim Speichern – UI zeigt keinen Saved-Status
|
||||||
|
|
@ -166,8 +189,93 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(importResult = null) }
|
_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 {
|
companion object {
|
||||||
const val KEY_HOUSEHOLD_SIZE = "household_size"
|
const val KEY_HOUSEHOLD_SIZE = "household_size"
|
||||||
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ import androidx.core.content.FileProvider
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
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.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.usecase.CalculateSupplyRangeUseCase
|
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||||
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -38,6 +41,7 @@ class SettingsViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
||||||
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
||||||
|
private lateinit var fakeSyncService: FakeSyncService
|
||||||
private lateinit var mockContext: Context
|
private lateinit var mockContext: Context
|
||||||
private lateinit var tempDir: File
|
private lateinit var tempDir: File
|
||||||
private lateinit var viewModel: SettingsViewModel
|
private lateinit var viewModel: SettingsViewModel
|
||||||
|
|
@ -47,6 +51,7 @@ class SettingsViewModelTest {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
fakeSettingsRepository = FakeSettingsRepository()
|
fakeSettingsRepository = FakeSettingsRepository()
|
||||||
fakeImportExportRepository = FakeImportExportRepository()
|
fakeImportExportRepository = FakeImportExportRepository()
|
||||||
|
fakeSyncService = FakeSyncService()
|
||||||
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
||||||
tempDir.mkdirs()
|
tempDir.mkdirs()
|
||||||
mockContext = mockk(relaxed = true) {
|
mockContext = mockk(relaxed = true) {
|
||||||
|
|
@ -64,6 +69,7 @@ class SettingsViewModelTest {
|
||||||
private fun createViewModel() = SettingsViewModel(
|
private fun createViewModel() = SettingsViewModel(
|
||||||
settingsRepository = fakeSettingsRepository,
|
settingsRepository = fakeSettingsRepository,
|
||||||
importExportRepository = fakeImportExportRepository,
|
importExportRepository = fakeImportExportRepository,
|
||||||
|
syncService = fakeSyncService,
|
||||||
context = mockContext
|
context = mockContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -446,6 +452,159 @@ class SettingsViewModelTest {
|
||||||
// Then
|
// Then
|
||||||
assertNull(viewModel.uiState.value.importResult)
|
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
|
// region Test Helpers
|
||||||
|
|
@ -472,6 +631,12 @@ private class FakeImportExportRepository : ImportExportRepository {
|
||||||
var markdownResult = "# Krisenvorrat Inventar\n"
|
var markdownResult = "# Krisenvorrat Inventar\n"
|
||||||
var shouldThrow = false
|
var shouldThrow = false
|
||||||
var importShouldFail = false
|
var importShouldFail = false
|
||||||
|
var inventoryDto = InventoryDto(
|
||||||
|
categories = emptyList(),
|
||||||
|
locations = emptyList(),
|
||||||
|
items = emptyList(),
|
||||||
|
settings = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun exportToJson(): String {
|
override suspend fun exportToJson(): String {
|
||||||
if (shouldThrow) throw RuntimeException("Test error")
|
if (shouldThrow) throw RuntimeException("Test error")
|
||||||
|
|
@ -487,6 +652,45 @@ private class FakeImportExportRepository : ImportExportRepository {
|
||||||
if (shouldThrow) throw RuntimeException("Test error")
|
if (shouldThrow) throw RuntimeException("Test error")
|
||||||
return markdownResult
|
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
|
// endregion
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue