diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt b/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt index 7efab59..35018de 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/dao/PendingSyncOpDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity +import kotlinx.coroutines.flow.Flow @Dao internal interface PendingSyncOpDao { @@ -20,4 +21,7 @@ internal interface PendingSyncOpDao { @Query("DELETE FROM pending_sync_ops WHERE item_id = :itemId") suspend fun deleteByItemId(itemId: String) + + @Query("SELECT COUNT(*) FROM pending_sync_ops") + fun getCount(): Flow } diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/ConnectionState.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/ConnectionState.kt new file mode 100644 index 0000000..192880c --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/ConnectionState.kt @@ -0,0 +1,8 @@ +package de.krisenvorrat.app.data.sync + +internal sealed interface ConnectionState { + data object Connected : ConnectionState + data object Connecting : ConnectionState + data class Disconnected(val reconnectInSeconds: Int) : ConnectionState + data object NotConfigured : ConnectionState +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt index c26229e..9034bce 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClient.kt @@ -2,9 +2,11 @@ package de.krisenvorrat.app.data.sync import de.krisenvorrat.shared.model.MessageDto import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow internal interface WebSocketClient { val events: SharedFlow + val connectionState: StateFlow fun connect(serverUrl: String, accessToken: String) fun disconnect() } diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt index d845a8b..9627b9a 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/WebSocketClientImpl.kt @@ -11,11 +11,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -30,8 +32,12 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { private val _events = MutableSharedFlow(extraBufferCapacity = 16) override val events: SharedFlow = _events.asSharedFlow() + private val _connectionState = MutableStateFlow(ConnectionState.NotConfigured) + override val connectionState: StateFlow = _connectionState.asStateFlow() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var connectionJob: Job? = null + private var countdownJob: Job? = null private val wsHttpClient = HttpClient(OkHttp) { install(WebSockets) @@ -39,11 +45,14 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { override fun connect(serverUrl: String, accessToken: String) { connectionJob?.cancel() + countdownJob?.cancel() + _connectionState.value = ConnectionState.Connecting connectionJob = scope.launch { var backoffMs = INITIAL_BACKOFF_MS var consecutiveFailures = 0 var connectionFailedEmitted = false while (isActive) { + _connectionState.value = ConnectionState.Connecting try { val wsUrl = serverUrl.trimEnd('/') .replace("https://", "wss://") @@ -52,6 +61,7 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { backoffMs = INITIAL_BACKOFF_MS consecutiveFailures = 0 connectionFailedEmitted = false + _connectionState.value = ConnectionState.Connected _events.emit(WebSocketEvent.Connected) for (frame in incoming) { if (frame is Frame.Text) { @@ -75,7 +85,9 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { if (!isActive) break _events.emit(WebSocketEvent.Disconnected) val jitter = backoffMs * JITTER_FACTOR * (Random.nextDouble() * 2 - 1) - delay(backoffMs + jitter.toLong()) + val totalDelayMs = backoffMs + jitter.toLong() + startCountdown(totalDelayMs) + delay(totalDelayMs) backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS) } } @@ -84,9 +96,23 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { override fun disconnect() { connectionJob?.cancel() connectionJob = null + countdownJob?.cancel() + countdownJob = null + _connectionState.value = ConnectionState.NotConfigured scope.launch { _events.emit(WebSocketEvent.Disconnected) } } + private fun startCountdown(totalDelayMs: Long) { + countdownJob?.cancel() + countdownJob = scope.launch { + val totalSeconds = ((totalDelayMs + 999) / 1000).toInt() + for (remaining in totalSeconds downTo 1) { + _connectionState.value = ConnectionState.Disconnected(reconnectInSeconds = remaining) + delay(1000L) + } + } + } + private suspend fun handleFrame(text: String) { try { val event = json.decodeFromString(text) diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index 7dfc1e3..d7d3c53 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -4,6 +4,9 @@ import android.content.Intent import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -37,12 +40,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color 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 +import de.krisenvorrat.app.data.sync.ConnectionState import de.krisenvorrat.app.domain.model.AgeGroup import de.krisenvorrat.app.domain.model.totalDailyKcal @@ -356,7 +361,11 @@ internal fun SettingsScreen( val isSyncEnabled = uiState.isLoggedIn && uiState.serverUrl.isNotBlank() && - uiState.syncStatus !is SyncStatus.Running + !uiState.isSyncing + + ConnectionStatusIndicator(uiState = uiState) + + Spacer(modifier = Modifier.height(8.dp)) Text( text = "Synchronisierung erfolgt automatisch.", @@ -373,37 +382,18 @@ internal fun SettingsScreen( Text("Jetzt synchronisieren") } - 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)) + if (uiState.isSyncing) { + 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 erfolgreich", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + text = "Synchronisierung läuft…", + style = MaterialTheme.typography.bodySmall ) } - 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) { @@ -497,3 +487,49 @@ internal fun SettingsScreen( } } +@Composable +private fun ConnectionStatusIndicator(uiState: SettingsUiState) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val (dotColor, statusText) = when (val state = uiState.connectionState) { + is ConnectionState.Connected -> Color(0xFF4CAF50) to "Verbunden" + is ConnectionState.Connecting -> Color(0xFFFFC107) to "Verbinde…" + is ConnectionState.Disconnected -> Color(0xFFF44336) to + "Keine Verbindung – nächster Versuch in ${state.reconnectInSeconds} Sek." + is ConnectionState.NotConfigured -> Color.Gray to "Nicht angemeldet" + } + + Text( + text = "●", + color = dotColor, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium + ) + } + + AnimatedVisibility( + visible = uiState.syncActivity != null, + enter = fadeIn(), + exit = fadeOut() + ) { + uiState.syncActivity?.let { activity -> + Text( + text = activity.text, + style = MaterialTheme.typography.bodySmall, + color = if (activity.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 20.dp, top = 4.dp) + ) + } + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index c79462b..35bc833 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -1,6 +1,7 @@ package de.krisenvorrat.app.ui.settings import android.net.Uri +import de.krisenvorrat.app.data.sync.ConnectionState import de.krisenvorrat.app.domain.model.AgeGroupEntry import de.krisenvorrat.app.domain.model.defaultAgeGroups @@ -21,18 +22,14 @@ internal data class SettingsUiState( val loginPassword: String = "", val isLoggingIn: Boolean = false, val loginError: String? = null, - val syncStatus: SyncStatus = SyncStatus.Idle, + val connectionState: ConnectionState = ConnectionState.NotConfigured, + val syncActivity: SyncActivityMessage? = null, + val pendingQueueCount: Int = 0, + val isSyncing: Boolean = false, val lastSyncTime: String? = null, val openAiApiKey: String = "" ) -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 diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 8f84d14..010bba7 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao +import de.krisenvorrat.app.data.sync.ConnectionState import de.krisenvorrat.app.data.sync.WebSocketClient import de.krisenvorrat.app.data.sync.WebSocketEvent import de.krisenvorrat.app.domain.model.AgeGroup @@ -19,6 +21,8 @@ import de.krisenvorrat.app.domain.model.toJson import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SyncService +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,28 +41,57 @@ internal class SettingsViewModel @Inject constructor( private val importExportRepository: ImportExportRepository, private val syncService: SyncService, private val webSocketClient: WebSocketClient, + private val pendingSyncOpDao: PendingSyncOpDao, @ApplicationContext private val context: Context ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var activityDismissJob: Job? = null + init { loadSettings() observeWebSocketEvents() + observeConnectionState() + observePendingQueueCount() + } + + private fun observeConnectionState() { + viewModelScope.launch { + webSocketClient.connectionState.collect { state -> + _uiState.update { it.copy(connectionState = state) } + } + } + } + + private fun observePendingQueueCount() { + viewModelScope.launch { + pendingSyncOpDao.getCount().collect { count -> + _uiState.update { it.copy(pendingQueueCount = count) } + if (count > 0) { + showActivity(SyncActivityMessage.QueuePending(count)) + } + } + } } private fun observeWebSocketEvents() { viewModelScope.launch { webSocketClient.events.collect { event -> when (event) { - is WebSocketEvent.FullSyncRequired -> pullSync(fullSync = true) - is WebSocketEvent.InventoryUpdated -> pullSync(fullSync = false) + is WebSocketEvent.FullSyncRequired -> { + showActivity(SyncActivityMessage.ReceivingUpdate) + pullSync(fullSync = true) + } + is WebSocketEvent.InventoryUpdated -> { + showActivity(SyncActivityMessage.ReceivingUpdate) + pullSync(fullSync = false) + } is WebSocketEvent.ConnectionFailed -> { - _uiState.update { it.copy(syncStatus = SyncStatus.Error(event.message)) } + showActivity(SyncActivityMessage.Error(event.message)) } is WebSocketEvent.Connected -> { - _uiState.update { it.copy(syncStatus = SyncStatus.Idle) } pullSync(fullSync = false) } else -> {} @@ -67,6 +100,15 @@ internal class SettingsViewModel @Inject constructor( } } + private fun showActivity(message: SyncActivityMessage) { + _uiState.update { it.copy(syncActivity = message) } + activityDismissJob?.cancel() + activityDismissJob = viewModelScope.launch { + delay(ACTIVITY_DISPLAY_DURATION_MS) + _uiState.update { it.copy(syncActivity = null) } + } + } + private fun loadSettings() { viewModelScope.launch { try { @@ -209,7 +251,8 @@ internal class SettingsViewModel @Inject constructor( it.copy( isLoggedIn = false, loggedInUsername = "", - syncStatus = SyncStatus.Idle + connectionState = ConnectionState.NotConfigured, + syncActivity = null ) } } @@ -355,7 +398,8 @@ internal class SettingsViewModel @Inject constructor( fun pushSync() { viewModelScope.launch { - _uiState.update { it.copy(syncStatus = SyncStatus.Running) } + _uiState.update { it.copy(isSyncing = true) } + showActivity(SyncActivityMessage.SendingChange) try { val inventoryDto = importExportRepository.exportToInventoryDto() val result = syncService.uploadInventory(inventoryDto) @@ -365,28 +409,28 @@ internal class SettingsViewModel @Inject constructor( settingsRepository.setString(StringKey.SyncLastTimestamp, now) _uiState.update { it.copy( - syncStatus = SyncStatus.Success, + isSyncing = false, lastSyncTime = formatTimestamp(now) ) } + showActivity(SyncActivityMessage.ChangeSent) }, onFailure = { e -> - _uiState.update { - it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen")) - } + _uiState.update { it.copy(isSyncing = false) } + showActivity(SyncActivityMessage.Error(e.message ?: "Push fehlgeschlagen")) } ) } catch (e: Exception) { - _uiState.update { - it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen")) - } + _uiState.update { it.copy(isSyncing = false) } + showActivity(SyncActivityMessage.Error(e.message ?: "Push fehlgeschlagen")) } } } fun pullSync(fullSync: Boolean = false) { viewModelScope.launch { - _uiState.update { it.copy(syncStatus = SyncStatus.Running) } + _uiState.update { it.copy(isSyncing = true) } + showActivity(SyncActivityMessage.ReceivingUpdate) try { val since = if (fullSync) null else settingsRepository.getStringOrNull(StringKey.SyncLastTimestamp)?.toLongOrNull() val result = syncService.downloadInventory(since) @@ -399,36 +443,30 @@ internal class SettingsViewModel @Inject constructor( loadSettings() _uiState.update { it.copy( - syncStatus = SyncStatus.Success, + isSyncing = false, lastSyncTime = formatTimestamp(now) ) } + showActivity(SyncActivityMessage.InventorySynced) }, onFailure = { e -> - _uiState.update { - it.copy(syncStatus = SyncStatus.Error(e.message ?: "Import fehlgeschlagen")) - } + _uiState.update { it.copy(isSyncing = false) } + showActivity(SyncActivityMessage.Error(e.message ?: "Import fehlgeschlagen")) } ) }, onFailure = { e -> - _uiState.update { - it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen")) - } + _uiState.update { it.copy(isSyncing = false) } + showActivity(SyncActivityMessage.Error(e.message ?: "Pull fehlgeschlagen")) } ) } catch (e: Exception) { - _uiState.update { - it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen")) - } + _uiState.update { it.copy(isSyncing = false) } + showActivity(SyncActivityMessage.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) @@ -436,4 +474,7 @@ internal class SettingsViewModel @Inject constructor( return formatter.format(instant.atZone(ZoneId.systemDefault())) } + private companion object { + const val ACTIVITY_DISPLAY_DURATION_MS = 3000L + } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SyncActivityMessage.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SyncActivityMessage.kt new file mode 100644 index 0000000..0381f95 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SyncActivityMessage.kt @@ -0,0 +1,30 @@ +package de.krisenvorrat.app.ui.settings + +internal sealed interface SyncActivityMessage { + val text: String + val isError: Boolean get() = false + + data object ReceivingUpdate : SyncActivityMessage { + override val text = "Empfange Inventar-Update…" + } + + data object SendingChange : SyncActivityMessage { + override val text = "Sende Änderung…" + } + + data object InventorySynced : SyncActivityMessage { + override val text = "Inventar synchronisiert" + } + + data object ChangeSent : SyncActivityMessage { + override val text = "Änderung gesendet" + } + + data class QueuePending(val count: Int) : SyncActivityMessage { + override val text = "$count Änderungen in Warteschlange" + } + + data class Error(override val text: String) : SyncActivityMessage { + override val isError = true + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt index c8b87a7..863ede9 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt @@ -115,6 +115,7 @@ private class FakePendingSyncOpDao : PendingSyncOpDao { override suspend fun getAll(): List = ops.toList() override suspend fun deleteById(id: String) { ops.removeAll { it.id == id } } override suspend fun deleteByItemId(itemId: String) { ops.removeAll { it.itemId == itemId } } + override fun getCount(): Flow = MutableStateFlow(ops.size) } private class FakeSettingsRepository : SettingsRepository { @@ -170,6 +171,8 @@ private class FakeSyncService : SyncService { private class FakeWebSocketClient : WebSocketClient { private val _events = MutableSharedFlow(extraBufferCapacity = 10) override val events: SharedFlow = _events + private val _connectionState = MutableStateFlow(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured) + override val connectionState: kotlinx.coroutines.flow.StateFlow = _connectionState suspend fun emit(event: WebSocketEvent) { _events.emit(event) } override fun connect(serverUrl: String, accessToken: String) {} override fun disconnect() {} diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt index 8adce83..afb4b60 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/MessageRepositoryImplTest.kt @@ -79,6 +79,8 @@ private class FakeMessageSettingsRepository : SettingsRepository { private class FakeMessageWsClient : WebSocketClient { val events2 = MutableSharedFlow(extraBufferCapacity = 16) override val events: SharedFlow = events2.asSharedFlow() + private val _connectionState = MutableStateFlow(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured) + override val connectionState: kotlinx.coroutines.flow.StateFlow = _connectionState override fun connect(serverUrl: String, accessToken: String) = Unit override fun disconnect() = Unit } diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index e4e5f80..94222d2 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.core.content.FileProvider import android.content.ContentResolver import android.net.Uri +import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao +import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity import de.krisenvorrat.app.data.sync.WebSocketClient import de.krisenvorrat.app.data.sync.WebSocketEvent import de.krisenvorrat.app.data.db.entity.SettingsEntity @@ -50,6 +52,7 @@ class SettingsViewModelTest { private lateinit var fakeImportExportRepository: FakeImportExportRepository private lateinit var fakeSyncService: FakeSyncService private lateinit var fakeWebSocketClient: FakeWebSocketClient + private lateinit var fakePendingSyncOpDao: FakePendingSyncOpDao private lateinit var mockContext: Context private lateinit var tempDir: File private lateinit var viewModel: SettingsViewModel @@ -61,6 +64,7 @@ class SettingsViewModelTest { fakeImportExportRepository = FakeImportExportRepository() fakeSyncService = FakeSyncService() fakeWebSocketClient = FakeWebSocketClient() + fakePendingSyncOpDao = FakePendingSyncOpDao() tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports") tempDir.mkdirs() mockContext = mockk(relaxed = true) { @@ -80,6 +84,7 @@ class SettingsViewModelTest { importExportRepository = fakeImportExportRepository, syncService = fakeSyncService, webSocketClient = fakeWebSocketClient, + pendingSyncOpDao = fakePendingSyncOpDao, context = mockContext ) @@ -703,13 +708,13 @@ class SettingsViewModelTest { // Then val state = viewModel.uiState.value - assertTrue(state.syncStatus is SyncStatus.Success) + assertFalse(state.isSyncing) assertNotNull(state.lastSyncTime) assertNotNull(fakeSettingsRepository.store[SettingsKey.StringKey.SyncLastTimestamp.key]) } @Test - fun test_pushSync_failure_setsSyncStatusError() = runTest(testDispatcher) { + fun test_pushSync_failure_setsSyncActivityError() = runTest(testDispatcher) { // Given fakeSyncService.uploadShouldFail = true fakeSyncService.uploadError = SyncError.ConnectionError() @@ -718,12 +723,14 @@ class SettingsViewModelTest { // When viewModel.pushSync() - advanceUntilIdle() + testScheduler.advanceTimeBy(100) + testScheduler.runCurrent() // Then val state = viewModel.uiState.value - assertTrue(state.syncStatus is SyncStatus.Error) - assertTrue((state.syncStatus as SyncStatus.Error).message.contains("Server nicht erreichbar")) + assertFalse(state.isSyncing) + assertTrue(state.syncActivity is SyncActivityMessage.Error) + assertTrue((state.syncActivity as SyncActivityMessage.Error).text.contains("Server nicht erreichbar")) } @Test @@ -738,13 +745,13 @@ class SettingsViewModelTest { // Then val state = viewModel.uiState.value - assertTrue(state.syncStatus is SyncStatus.Success) + assertFalse(state.isSyncing) assertNotNull(state.lastSyncTime) assertNotNull(fakeSettingsRepository.store[SettingsKey.StringKey.SyncLastTimestamp.key]) } @Test - fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) { + fun test_pullSync_authError_setsSyncActivityError() = runTest(testDispatcher) { // Given fakeSyncService.downloadShouldFail = true fakeSyncService.downloadError = de.krisenvorrat.app.domain.model.SyncError.AuthError() @@ -753,27 +760,14 @@ class SettingsViewModelTest { // When viewModel.pullSync() - advanceUntilIdle() + testScheduler.advanceTimeBy(100) + testScheduler.runCurrent() // 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) + assertFalse(state.isSyncing) + assertTrue(state.syncActivity is SyncActivityMessage.Error) + assertTrue((state.syncActivity as SyncActivityMessage.Error).text.contains("Authentifizierung")) } } @@ -901,9 +895,20 @@ private class FakeSyncService : SyncService { Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true)) } +private class FakePendingSyncOpDao : PendingSyncOpDao { + private val _countFlow = kotlinx.coroutines.flow.MutableStateFlow(0) + override suspend fun insert(op: PendingSyncOpEntity) {} + override suspend fun getAll(): List = emptyList() + override suspend fun deleteById(id: String) {} + override suspend fun deleteByItemId(itemId: String) {} + override fun getCount(): kotlinx.coroutines.flow.Flow = _countFlow +} + private class FakeWebSocketClient : WebSocketClient { private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 16) override val events: kotlinx.coroutines.flow.SharedFlow = _events + private val _connectionState = kotlinx.coroutines.flow.MutableStateFlow(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured) + override val connectionState: kotlinx.coroutines.flow.StateFlow = _connectionState var connectedUrl: String? = null override fun connect(serverUrl: String, accessToken: String) { connectedUrl = serverUrl } override fun disconnect() { connectedUrl = null }