feat: Sync-Statusanzeige mit Live-Verbindungsstatus und Aktivitaets-Feed (#94)

- ConnectionState sealed interface (Connected/Connecting/Disconnected/NotConfigured)
- WebSocketClientImpl: connectionState StateFlow mit Reconnect-Countdown-Timer
- SyncActivityMessage: Aktivitaets-Feed-Modell mit 3-Sek-Auto-Dismiss
- PendingSyncOpDao: getCount() Flow fuer Queue-Groesse
- SettingsUiState: SyncStatus durch ConnectionState + SyncActivity ersetzt
- SettingsViewModel: observeConnectionState, observePendingQueueCount,
  showActivity mit Job-basiertem Timer-Reset
- SettingsScreen: Farbiger Punkt + Statustext, AnimatedVisibility
  fuer Aktivitaets-Feed, Countdown bei Disconnected
- Alle 306 Tests gruen
This commit is contained in:
Jens Reinemann 2026-05-17 16:02:55 +02:00
parent bba4ac0086
commit 47a2865b34
11 changed files with 246 additions and 92 deletions

View file

@ -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<Int>
}

View file

@ -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
}

View file

@ -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<WebSocketEvent>
val connectionState: StateFlow<ConnectionState>
fun connect(serverUrl: String, accessToken: String)
fun disconnect()
}

View file

@ -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<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = _events.asSharedFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.NotConfigured)
override val connectionState: StateFlow<ConnectionState> = _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<WsServerEvent>(text)

View file

@ -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,8 +382,7 @@ internal fun SettingsScreen(
Text("Jetzt synchronisieren")
}
when (val syncStatus = uiState.syncStatus) {
is SyncStatus.Running -> {
if (uiState.isSyncing) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
@ -387,24 +395,6 @@ internal fun SettingsScreen(
)
}
}
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))
@ -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)
)
}
}
}
}

View file

@ -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

View file

@ -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<SettingsUiState> = _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,35 +443,29 @@ 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
@ -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
}
}

View file

@ -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
}
}

View file

@ -115,6 +115,7 @@ private class FakePendingSyncOpDao : PendingSyncOpDao {
override suspend fun getAll(): List<PendingSyncOpEntity> = 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<Int> = MutableStateFlow(ops.size)
}
private class FakeSettingsRepository : SettingsRepository {
@ -170,6 +171,8 @@ private class FakeSyncService : SyncService {
private class FakeWebSocketClient : WebSocketClient {
private val _events = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 10)
override val events: SharedFlow<WebSocketEvent> = _events
private val _connectionState = MutableStateFlow<de.krisenvorrat.app.data.sync.ConnectionState>(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured)
override val connectionState: kotlinx.coroutines.flow.StateFlow<de.krisenvorrat.app.data.sync.ConnectionState> = _connectionState
suspend fun emit(event: WebSocketEvent) { _events.emit(event) }
override fun connect(serverUrl: String, accessToken: String) {}
override fun disconnect() {}

View file

@ -79,6 +79,8 @@ private class FakeMessageSettingsRepository : SettingsRepository {
private class FakeMessageWsClient : WebSocketClient {
val events2 = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = events2.asSharedFlow()
private val _connectionState = MutableStateFlow<de.krisenvorrat.app.data.sync.ConnectionState>(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured)
override val connectionState: kotlinx.coroutines.flow.StateFlow<de.krisenvorrat.app.data.sync.ConnectionState> = _connectionState
override fun connect(serverUrl: String, accessToken: String) = Unit
override fun disconnect() = Unit
}

View file

@ -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<PendingSyncOpEntity> = emptyList()
override suspend fun deleteById(id: String) {}
override suspend fun deleteByItemId(itemId: String) {}
override fun getCount(): kotlinx.coroutines.flow.Flow<Int> = _countFlow
}
private class FakeWebSocketClient : WebSocketClient {
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
override val events: kotlinx.coroutines.flow.SharedFlow<WebSocketEvent> = _events
private val _connectionState = kotlinx.coroutines.flow.MutableStateFlow<de.krisenvorrat.app.data.sync.ConnectionState>(de.krisenvorrat.app.data.sync.ConnectionState.NotConfigured)
override val connectionState: kotlinx.coroutines.flow.StateFlow<de.krisenvorrat.app.data.sync.ConnectionState> = _connectionState
var connectedUrl: String? = null
override fun connect(serverUrl: String, accessToken: String) { connectedUrl = serverUrl }
override fun disconnect() { connectedUrl = null }