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:
parent
bba4ac0086
commit
47a2865b34
11 changed files with 246 additions and 92 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue