From df37d2a47c2b36ac1a4966395375f954066b7865 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 9 Feb 2026 10:38:47 +0100 Subject: [PATCH] feat(v1.8.0): IMPL_006 Sync Progress UI - Complete Implementation - Add SyncProgress.kt: Data class for entire sync lifecycle UI state - Add SyncPhase enum: IDLE, PREPARING, UPLOADING, DOWNLOADING, IMPORTING_MARKDOWN, COMPLETED, ERROR - Rewrite SyncStateManager.kt: SyncProgress (StateFlow) is single source of truth - Remove pre-set phases: CHECKING_SERVER and SAVING cause flickering - UPLOADING phase only set when actual uploads happen - DOWNLOADING phase only set when actual downloads happen - IMPORTING_MARKDOWN phase only set when feature enabled - Add onProgress callback to uploadLocalNotes() with uploadedCount/totalToUpload - Add onProgress callback to downloadRemoteNotes() for actual downloads only - Progress display: x/y for uploads (known total), count for downloads (unknown) - Add SyncProgressBanner.kt: Unified banner (replaces dual system) - Update SyncStatusBanner.kt: Kept for legacy compatibility, only COMPLETED/ERROR - Update MainViewModel.kt: Remove _syncMessage, add syncProgress StateFlow - Update MainScreen.kt: Use only SyncProgressBanner (unified) - Update ComposeMainActivity.kt: Auto-hide COMPLETED (2s), ERROR (4s) via lifecycle - Add strings.xml (DE+EN): sync_phase_* and sync_wifi_only_error - Banner appears instantly on sync button click (PREPARING phase) - Silent auto-sync (onResume) completely silent, errors always shown - No misleading counters when nothing to sync Closes #IMPL_006 --- .../dettmer/simplenotes/sync/SyncProgress.kt | 99 +++++++++ .../simplenotes/sync/SyncStateManager.kt | 152 ++++++++++---- .../simplenotes/sync/WebDavSyncService.kt | 72 ++++++- .../ui/main/ComposeMainActivity.kt | 25 +-- .../dettmer/simplenotes/ui/main/MainScreen.kt | 15 +- .../simplenotes/ui/main/MainViewModel.kt | 31 +-- .../ui/main/components/SyncProgressBanner.kt | 191 ++++++++++++++++++ .../ui/main/components/SyncStatusBanner.kt | 23 +-- .../app/src/main/res/values-de/strings.xml | 11 + android/app/src/main/res/values/strings.xml | 11 + 10 files changed, 536 insertions(+), 94 deletions(-) create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt new file mode 100644 index 0000000..f2f25c0 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncProgress.kt @@ -0,0 +1,99 @@ +package dev.dettmer.simplenotes.sync + +/** + * πŸ†• v1.8.0: Detaillierter Sync-Fortschritt fΓΌr UI + * + * Einziges Banner-System fΓΌr den gesamten Sync-Lebenszyklus: + * - PREPARING: Sofort beim Klick, bleibt wΓ€hrend Vor-Checks und Server-PrΓΌfung + * - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen + * - COMPLETED / ERROR: Ergebnis mit Nachricht + Auto-Hide + * + * Ersetzt das alte duale Banner-System (SyncStatusBanner + SyncProgressBanner) + */ +data class SyncProgress( + val phase: SyncPhase = SyncPhase.IDLE, + val current: Int = 0, + val total: Int = 0, + val currentFileName: String? = null, + val resultMessage: String? = null, + val silent: Boolean = false, + val startTime: Long = System.currentTimeMillis() +) { + /** + * Fortschritt als Float zwischen 0.0 und 1.0 + */ + val progress: Float + get() = if (total > 0) current.toFloat() / total else 0f + + /** + * Fortschritt als Prozent (0-100) + */ + val percentComplete: Int + get() = (progress * 100).toInt() + + /** + * Vergangene Zeit seit Start in Millisekunden + */ + val elapsedMs: Long + get() = System.currentTimeMillis() - startTime + + /** + * GeschΓ€tzte verbleibende Zeit in Millisekunden + * Basiert auf durchschnittlicher Zeit pro Item + */ + val estimatedRemainingMs: Long? + get() { + if (current == 0 || total == 0) return null + val avgTimePerItem = elapsedMs / current + val remaining = total - current + return avgTimePerItem * remaining + } + + /** + * Ob das Banner sichtbar sein soll + * Silent syncs zeigen nie ein Banner + */ + val isVisible: Boolean + get() = !silent && phase != SyncPhase.IDLE + + /** + * Ob gerade ein aktiver Sync lΓ€uft (mit Spinner) + */ + val isActiveSync: Boolean + get() = phase in listOf( + SyncPhase.PREPARING, + SyncPhase.UPLOADING, + SyncPhase.DOWNLOADING, + SyncPhase.IMPORTING_MARKDOWN + ) + + companion object { + val IDLE = SyncProgress(phase = SyncPhase.IDLE) + } +} + +/** + * πŸ†• v1.8.0: Sync-Phasen fΓΌr detailliertes Progress-Tracking + */ +enum class SyncPhase { + /** Kein Sync aktiv */ + IDLE, + + /** Sync wurde gestartet, Vor-Checks laufen (hasUnsyncedChanges, isReachable, Server-Verzeichnis) */ + PREPARING, + + /** LΓ€dt lokale Γ„nderungen auf den Server hoch */ + UPLOADING, + + /** LΓ€dt Server-Γ„nderungen herunter */ + DOWNLOADING, + + /** Importiert Markdown-Dateien vom Server */ + IMPORTING_MARKDOWN, + + /** Sync erfolgreich abgeschlossen */ + COMPLETED, + + /** Sync mit Fehler abgebrochen */ + ERROR +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt index e43f513..ccdbd52 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncStateManager.kt @@ -3,46 +3,53 @@ package dev.dettmer.simplenotes.sync import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import dev.dettmer.simplenotes.utils.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * πŸ”„ v1.3.1: Zentrale Verwaltung des Sync-Status + * πŸ†• v1.8.0: Komplett ΓΌberarbeitet - SyncProgress ist jetzt das einzige Banner-System * - * Verhindert doppelte Syncs und informiert die UI ΓΌber den aktuellen Status. - * Thread-safe Singleton mit LiveData fΓΌr UI-ReaktivitΓ€t. + * SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus: + * PREPARING β†’ [UPLOADING] β†’ [DOWNLOADING] β†’ [IMPORTING_MARKDOWN] β†’ COMPLETED/ERROR β†’ IDLE + * + * SyncStatus (LiveData) wird nur noch intern fΓΌr Mutex/Silent-Tracking verwendet. */ object SyncStateManager { private const val TAG = "SyncStateManager" /** - * MΓΆgliche Sync-ZustΓ€nde + * MΓΆgliche Sync-ZustΓ€nde (intern fΓΌr Mutex + PullToRefresh) */ enum class SyncState { - IDLE, // Kein Sync aktiv - SYNCING, // Sync lΓ€uft gerade (Banner sichtbar) - SYNCING_SILENT, // v1.5.0: Sync lΓ€uft im Hintergrund (kein Banner, z.B. onResume) - COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen) - ERROR // Sync fehlgeschlagen (kurz anzeigen) + IDLE, + SYNCING, + SYNCING_SILENT, + COMPLETED, + ERROR } /** - * Detaillierte Sync-Informationen fΓΌr UI + * Interne Sync-Informationen (fΓΌr Mutex-Management + Silent-Tracking) */ data class SyncStatus( val state: SyncState = SyncState.IDLE, val message: String? = null, - val source: String? = null, // "manual", "auto", "pullToRefresh", "background" - val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt + val source: String? = null, + val silent: Boolean = false, val timestamp: Long = System.currentTimeMillis() ) - // Private mutable LiveData + // Intern: Mutex + PullToRefresh State private val _syncStatus = MutableLiveData(SyncStatus()) - - // Public immutable LiveData fΓΌr Observer val syncStatus: LiveData = _syncStatus - // Lock fΓΌr Thread-Sicherheit + // πŸ†• v1.8.0: Einziges Banner-System - SyncProgress + private val _syncProgress = MutableStateFlow(SyncProgress.IDLE) + val syncProgress: StateFlow = _syncProgress.asStateFlow() + private val lock = Any() /** @@ -56,54 +63,63 @@ object SyncStateManager { /** * Versucht einen Sync zu starten. - * @param source Quelle des Syncs (fΓΌr Logging) - * @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync) - * @return true wenn Sync gestartet werden kann, false wenn bereits einer lΓ€uft + * Bei silent=false: Setzt sofort PREPARING-Phase β†’ Banner erscheint instant + * Bei silent=true: Setzt silent-Flag β†’ kein Banner wird angezeigt */ fun tryStartSync(source: String, silent: Boolean = false): Boolean { synchronized(lock) { if (isSyncing) { - Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source") + Logger.d(TAG, "⚠️ Sync already in progress, rejecting from: $source") return false } val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING Logger.d(TAG, "πŸ”„ Starting sync from: $source (silent=$silent)") + _syncStatus.postValue( SyncStatus( state = syncState, - message = "Synchronisiere...", source = source, - silent = silent // v1.5.0: Merkt sich ob silent fΓΌr markCompleted() + silent = silent ) ) + + // πŸ†• v1.8.0: Sofort PREPARING-Phase setzen (Banner erscheint instant) + _syncProgress.value = SyncProgress( + phase = SyncPhase.PREPARING, + silent = silent, + startTime = System.currentTimeMillis() + ) + return true } } /** * Markiert Sync als erfolgreich abgeschlossen - * v1.5.0: Bei Silent-Sync direkt auf IDLE wechseln (kein Banner) + * Bei Silent-Sync: direkt auf IDLE (kein Banner) + * Bei normalem Sync: COMPLETED mit Nachricht (auto-hide durch UI) */ fun markCompleted(message: String? = null) { synchronized(lock) { val current = _syncStatus.value - val currentSource = current?.source val wasSilent = current?.silent == true + val currentSource = current?.source Logger.d(TAG, "βœ… Sync completed from: $currentSource (silent=$wasSilent)") if (wasSilent) { - // v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen + // Silent-Sync: Direkt auf IDLE - kein Banner _syncStatus.postValue(SyncStatus()) + _syncProgress.value = SyncProgress.IDLE } else { - // Normaler Sync - COMPLETED State anzeigen + // Normaler Sync: COMPLETED mit Nachricht anzeigen _syncStatus.postValue( - SyncStatus( - state = SyncState.COMPLETED, - message = message, - source = currentSource - ) + SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource) + ) + _syncProgress.value = SyncProgress( + phase = SyncPhase.COMPLETED, + resultMessage = message ) } } @@ -111,38 +127,90 @@ object SyncStateManager { /** * Markiert Sync als fehlgeschlagen + * Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig fΓΌr User) */ fun markError(errorMessage: String?) { synchronized(lock) { - val currentSource = _syncStatus.value?.source + val current = _syncStatus.value + val wasSilent = current?.silent == true + val currentSource = current?.source + Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage") + _syncStatus.postValue( - SyncStatus( - state = SyncState.ERROR, - message = errorMessage, - source = currentSource - ) + SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource) + ) + + // Fehler immer anzeigen (auch bei Silent-Sync) + _syncProgress.value = SyncProgress( + phase = SyncPhase.ERROR, + resultMessage = errorMessage, + silent = false // Fehler nie silent ) } } /** - * Setzt Status zurΓΌck auf IDLE + * Setzt alles zurΓΌck auf IDLE */ fun reset() { synchronized(lock) { _syncStatus.postValue(SyncStatus()) + _syncProgress.value = SyncProgress.IDLE + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // πŸ†• v1.8.0: Detailliertes Progress-Tracking (wΓ€hrend syncNotes()) + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Aktualisiert den detaillierten Sync-Fortschritt + * BehΓ€lt silent-Flag und startTime der aktuellen Session bei + */ + fun updateProgress( + phase: SyncPhase, + current: Int = 0, + total: Int = 0, + currentFileName: String? = null + ) { + synchronized(lock) { + val existing = _syncProgress.value + _syncProgress.value = SyncProgress( + phase = phase, + current = current, + total = total, + currentFileName = currentFileName, + silent = existing.silent, + startTime = existing.startTime + ) } } /** - * Aktualisiert die Nachricht wΓ€hrend des Syncs (z.B. Progress) + * Inkrementiert den Fortschritt um 1 + * Praktisch fΓΌr Schleifen: nach jedem tatsΓ€chlichen Download */ - fun updateMessage(message: String) { + fun incrementProgress(currentFileName: String? = null) { synchronized(lock) { - val current = _syncStatus.value ?: return - if (current.state == SyncState.SYNCING) { - _syncStatus.postValue(current.copy(message = message)) + val current = _syncProgress.value + _syncProgress.value = current.copy( + current = current.current + 1, + currentFileName = currentFileName + ) + } + } + + /** + * Setzt Progress zurΓΌck auf IDLE (am Ende von syncNotes()) + * Wird NICHT fΓΌr COMPLETED/ERROR verwendet - nur fΓΌr Cleanup + */ + fun resetProgress() { + // Nicht zurΓΌcksetzen wenn COMPLETED/ERROR - die UI braucht den State noch fΓΌr auto-hide + synchronized(lock) { + val current = _syncProgress.value + if (current.phase != SyncPhase.COMPLETED && current.phase != SyncPhase.ERROR) { + _syncProgress.value = SyncProgress.IDLE } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index 9faebba..724391d 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -597,6 +597,8 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, "Thread: ${Thread.currentThread().name}") return@withContext try { + // πŸ†• v1.8.0: Banner bleibt in PREPARING bis echte Arbeit (Upload/Download) anfΓ€llt + Logger.d(TAG, "πŸ“ Step 1: Getting Sardine client") val sardine = try { @@ -640,11 +642,23 @@ class WebDavSyncService(private val context: Context) { // Ensure notes-md/ directory exists (for Markdown export) ensureMarkdownDirectoryExists(sardine, serverUrl) + // πŸ†• v1.8.0: Phase 2 - Uploading (Phase wird nur bei echten Uploads gesetzt) Logger.d(TAG, "πŸ“ Step 4: Uploading local notes") // Upload local notes try { Logger.d(TAG, "⬆️ Uploading local notes...") - val uploadedCount = uploadLocalNotes(sardine, serverUrl) + val uploadedCount = uploadLocalNotes( + sardine, + serverUrl, + onProgress = { current, total, noteTitle -> + SyncStateManager.updateProgress( + phase = SyncPhase.UPLOADING, + current = current, + total = total, + currentFileName = noteTitle + ) + } + ) syncedCount += uploadedCount Logger.d(TAG, "βœ… Uploaded: $uploadedCount notes") } catch (e: Exception) { @@ -653,6 +667,7 @@ class WebDavSyncService(private val context: Context) { throw e } + // πŸ†• v1.8.0: Phase 3 - Downloading (Phase wird nur bei echten Downloads gesetzt) Logger.d(TAG, "πŸ“ Step 5: Downloading remote notes") // Download remote notes var deletedOnServerCount = 0 // πŸ†• v1.8.0 @@ -661,7 +676,17 @@ class WebDavSyncService(private val context: Context) { val downloadResult = downloadRemoteNotes( sardine, serverUrl, - includeRootFallback = true // βœ… v1.3.0: Enable for v1.2.0 compatibility + includeRootFallback = true, // βœ… v1.3.0: Enable for v1.2.0 compatibility + onProgress = { current, _, noteTitle -> + // πŸ†• v1.8.0: Phase wird erst beim ersten echten Download gesetzt + // current = laufender ZΓ€hler (downloadedCount), kein Total β†’ kein irrefΓΌhrender x/y Counter + SyncStateManager.updateProgress( + phase = SyncPhase.DOWNLOADING, + current = current, + total = 0, + currentFileName = noteTitle + ) + } ) syncedCount += downloadResult.downloadedCount conflictCount += downloadResult.conflictCount @@ -679,11 +704,15 @@ class WebDavSyncService(private val context: Context) { } Logger.d(TAG, "πŸ“ Step 6: Auto-import Markdown (if enabled)") + // Auto-import Markdown files from server var markdownImportedCount = 0 try { val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) if (markdownAutoImportEnabled) { + // πŸ†• v1.8.0: Phase nur setzen wenn Feature aktiv + SyncStateManager.updateProgress(phase = SyncPhase.IMPORTING_MARKDOWN) + Logger.d(TAG, "πŸ“₯ Auto-importing Markdown files...") markdownImportedCount = importMarkdownFiles(sardine, serverUrl) Logger.d(TAG, "βœ… Auto-imported: $markdownImportedCount Markdown files") @@ -704,6 +733,7 @@ class WebDavSyncService(private val context: Context) { } Logger.d(TAG, "πŸ“ Step 7: Saving sync timestamp") + // Update last sync timestamp try { saveLastSyncTimestamp() @@ -732,6 +762,13 @@ class WebDavSyncService(private val context: Context) { } Logger.d(TAG, "═══════════════════════════════════════") + // πŸ†• v1.8.0: Phase 6 - Completed + SyncStateManager.updateProgress( + phase = SyncPhase.COMPLETED, + current = effectiveSyncedCount, + total = effectiveSyncedCount + ) + SyncResult( isSuccess = true, syncedCount = effectiveSyncedCount, @@ -748,6 +785,9 @@ class WebDavSyncService(private val context: Context) { e.printStackTrace() Logger.e(TAG, "═══════════════════════════════════════") + // πŸ†• v1.8.0: Phase ERROR + SyncStateManager.updateProgress(phase = SyncPhase.ERROR) + SyncResult( isSuccess = false, errorMessage = when (e) { @@ -770,6 +810,8 @@ class WebDavSyncService(private val context: Context) { } finally { // ⚑ v1.3.1: Session-Caches leeren clearSessionCache() + // πŸ†• v1.8.0: Reset progress state + SyncStateManager.resetProgress() // πŸ”’ v1.3.1: Sync-Mutex freigeben syncMutex.unlock() } @@ -777,11 +819,21 @@ class WebDavSyncService(private val context: Context) { @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") // Sync logic requires nested conditions for comprehensive error handling and state management - private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { + private fun uploadLocalNotes( + sardine: Sardine, + serverUrl: String, + onProgress: (current: Int, total: Int, noteTitle: String) -> Unit = { _, _, _ -> } // πŸ†• v1.8.0 + ): Int { var uploadedCount = 0 val localNotes = storage.loadAllNotes() val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) + // πŸ†• v1.8.0: ZΓ€hle zu uploadende Notizen fΓΌr Progress + val pendingNotes = localNotes.filter { + it.syncStatus == SyncStatus.LOCAL_ONLY || it.syncStatus == SyncStatus.PENDING + } + val totalToUpload = pendingNotes.size + // πŸ”§ v1.7.2 (IMPL_004): Batch E-Tag Updates fΓΌr Performance val etagUpdates = mutableMapOf() @@ -805,6 +857,9 @@ class WebDavSyncService(private val context: Context) { storage.saveNote(noteToUpload) uploadedCount++ + // πŸ†• v1.8.0: Progress mit Notiz-Titel + onProgress(uploadedCount, totalToUpload, note.title) + // ⚑ v1.3.1: Refresh E-Tag after upload to prevent re-download // πŸ”§ v1.7.2 (IMPL_004): Sammle E-Tags fΓΌr Batch-Update try { @@ -1107,7 +1162,8 @@ class WebDavSyncService(private val context: Context) { serverUrl: String, includeRootFallback: Boolean = false, // πŸ†• v1.2.2: Only for restore from server forceOverwrite: Boolean = false, // πŸ†• v1.3.0: For OVERWRITE_DUPLICATES mode - deletionTracker: DeletionTracker = storage.loadDeletionTracker() // πŸ†• v1.3.0: Allow passing fresh tracker + deletionTracker: DeletionTracker = storage.loadDeletionTracker(), // πŸ†• v1.3.0: Allow passing fresh tracker + onProgress: (current: Int, total: Int, fileName: String) -> Unit = { _, _, _ -> } // πŸ†• v1.8.0 ): DownloadResult { var downloadedCount = 0 var conflictCount = 0 @@ -1145,7 +1201,7 @@ class WebDavSyncService(private val context: Context) { serverNoteIds.add(noteId) } - for (resource in jsonFiles) { + for ((index, resource) in jsonFiles.withIndex()) { val noteId = resource.name.removeSuffix(".json") val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name @@ -1235,6 +1291,8 @@ class WebDavSyncService(private val context: Context) { // New note from server storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) downloadedCount++ + // πŸ†• v1.8.0: Progress mit Notiz-Titel (kein Total β†’ kein irrefΓΌhrender Counter) + onProgress(downloadedCount, 0, remoteNote.title) Logger.d(TAG, " βœ… Downloaded from /notes/: ${remoteNote.id}") // ⚑ Cache E-Tag for next sync @@ -1246,6 +1304,7 @@ class WebDavSyncService(private val context: Context) { // OVERWRITE mode: Always replace regardless of timestamps storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) downloadedCount++ + onProgress(downloadedCount, 0, remoteNote.title) Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") // ⚑ Cache E-Tag for next sync @@ -1259,10 +1318,12 @@ class WebDavSyncService(private val context: Context) { // Conflict detected storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) conflictCount++ + // πŸ†• v1.8.0: Conflict zΓ€hlt nicht als Download } else { // Safe to overwrite storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) downloadedCount++ + onProgress(downloadedCount, 0, remoteNote.title) Logger.d(TAG, " βœ… Updated from /notes/: ${remoteNote.id}") // ⚑ Cache E-Tag for next sync @@ -1271,6 +1332,7 @@ class WebDavSyncService(private val context: Context) { } } } + // else: Local is newer or same β†’ skip silently } } Logger.d( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt index 40382bf..e00b4e1 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt @@ -219,25 +219,26 @@ class ComposeMainActivity : ComponentActivity() { } private fun setupSyncStateObserver() { + // πŸ†• v1.8.0: SyncStatus nur noch fΓΌr PullToRefresh-Indikator (intern) SyncStateManager.syncStatus.observe(this) { status -> viewModel.updateSyncState(status) - - @Suppress("MagicNumber") // UI timing delays for banner visibility - // Hide banner after delay for completed/error states - when (status.state) { - SyncStateManager.SyncState.COMPLETED -> { - lifecycleScope.launch { - kotlinx.coroutines.delay(1500L) + } + + // πŸ†• v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System) + lifecycleScope.launch { + SyncStateManager.syncProgress.collect { progress -> + @Suppress("MagicNumber") // UI timing delays for banner visibility + when (progress.phase) { + dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> { + kotlinx.coroutines.delay(2000L) SyncStateManager.reset() } - } - SyncStateManager.SyncState.ERROR -> { - lifecycleScope.launch { - kotlinx.coroutines.delay(3000L) + dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> { + kotlinx.coroutines.delay(4000L) SyncStateManager.reset() } + else -> { /* No action needed */ } } - else -> { /* No action needed */ } } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt index 99602aa..f4159a4 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState @@ -53,7 +54,7 @@ import dev.dettmer.simplenotes.ui.main.components.EmptyState import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB import dev.dettmer.simplenotes.ui.main.components.NotesList import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid -import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner +import dev.dettmer.simplenotes.ui.main.components.SyncProgressBanner import dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog import kotlinx.coroutines.launch @@ -78,9 +79,11 @@ fun MainScreen( ) { val notes by viewModel.notes.collectAsState() val syncState by viewModel.syncState.collectAsState() - val syncMessage by viewModel.syncMessage.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState() + // πŸ†• v1.8.0: Einziges Banner-System + val syncProgress by viewModel.syncProgress.collectAsState() + // Multi-Select State val selectedNotes by viewModel.selectedNotes.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState() @@ -197,10 +200,10 @@ fun MainScreen( Box(modifier = Modifier.fillMaxSize()) { // Main content column Column(modifier = Modifier.fillMaxSize()) { - // Sync Status Banner (not affected by pull-to-refresh) - SyncStatusBanner( - syncState = syncState, - message = syncMessage + // πŸ†• v1.8.0: Einziges Sync Banner (Progress + Ergebnis) + SyncProgressBanner( + progress = syncProgress, + modifier = Modifier.fillMaxWidth() ) // Content: Empty state or notes list diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index 940e46c..eb02412 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.storage.NotesStorage +import dev.dettmer.simplenotes.sync.SyncProgress import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.utils.Constants @@ -102,15 +103,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } // ═══════════════════════════════════════════════════════════════════════ - // Sync State (derived from SyncStateManager) + // Sync State // ═══════════════════════════════════════════════════════════════════════ + // πŸ†• v1.8.0: Einziges Banner-System - SyncProgress + val syncProgress: StateFlow = SyncStateManager.syncProgress + + // Intern: SyncState fΓΌr PullToRefresh-Indikator private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE) val syncState: StateFlow = _syncState.asStateFlow() - private val _syncMessage = MutableStateFlow(null) - val syncMessage: StateFlow = _syncMessage.asStateFlow() - // ═══════════════════════════════════════════════════════════════════════ // UI Events // ═══════════════════════════════════════════════════════════════════════ @@ -495,12 +497,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun updateSyncState(status: SyncStateManager.SyncStatus) { _syncState.value = status.state - _syncMessage.value = status.message } /** * Trigger manual sync (from toolbar button or pull-to-refresh) * v1.7.0: Uses central canSync() gate for WiFi-only check + * v1.8.0: Banner erscheint sofort beim Klick (PREPARING-Phase) */ fun triggerManualSync(source: String = "manual") { // πŸ†• v1.7.0: Zentrale Sync-Gate PrΓΌfung (inkl. WiFi-Only, Offline Mode, Server Config) @@ -509,7 +511,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (!gateResult.canSync) { if (gateResult.isBlockedByWifiOnly) { Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi") - SyncStateManager.markError(getString(R.string.sync_wifi_only_hint)) + SyncStateManager.markError(getString(R.string.sync_wifi_only_error)) } else { Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}") } @@ -517,6 +519,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } // πŸ†• v1.7.0: Feedback wenn Sync bereits lΓ€uft + // πŸ†• v1.8.0: tryStartSync setzt sofort PREPARING β†’ Banner erscheint instant if (!SyncStateManager.tryStartSync(source)) { if (SyncStateManager.isSyncing) { Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress") @@ -533,11 +536,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { try { - // Check for unsynced changes + // Check for unsynced changes (Banner zeigt bereits PREPARING) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") - val message = getApplication().getString(R.string.toast_already_synced) - SyncStateManager.markCompleted(message) + SyncStateManager.markCompleted(getString(R.string.toast_already_synced)) loadNotes() return@launch } @@ -614,7 +616,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } - // v1.5.0: silent=true - kein Banner bei Auto-Sync, aber Fehler werden trotzdem angezeigt + // v1.5.0: silent=true β†’ kein Banner bei Auto-Sync + // πŸ†• v1.8.0: tryStartSync mit silent=true β†’ SyncProgress.silent=true β†’ Banner unsichtbar if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) { Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") return @@ -630,7 +633,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // Check for unsynced changes if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") - SyncStateManager.reset() + SyncStateManager.reset() // Silent β†’ geht direkt auf IDLE return@launch } @@ -641,7 +644,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (!isReachable) { Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") - SyncStateManager.reset() + SyncStateManager.reset() // Silent β†’ kein Error-Banner return@launch } @@ -652,14 +655,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { if (result.isSuccess && result.syncedCount > 0) { Logger.d(TAG, "βœ… Auto-sync successful ($source): ${result.syncedCount} notes") + // Silent Sync mit echten Γ„nderungen β†’ trotzdem markCompleted (wird silent behandelt) SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount)) _showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount)) loadNotes() } else if (result.isSuccess) { Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes") - SyncStateManager.markCompleted(getString(R.string.snackbar_nothing_to_sync)) + SyncStateManager.markCompleted() // Silent β†’ geht direkt auf IDLE } else { Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") + // Fehler werden IMMER angezeigt (auch bei Silent-Sync) SyncStateManager.markError(result.errorMessage) } } catch (e: Exception) { diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt new file mode 100644 index 0000000..2f397d8 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncProgressBanner.kt @@ -0,0 +1,191 @@ +package dev.dettmer.simplenotes.ui.main.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.dettmer.simplenotes.R +import dev.dettmer.simplenotes.sync.SyncPhase +import dev.dettmer.simplenotes.sync.SyncProgress + +/** + * πŸ†• v1.8.0: Einziges Sync-Banner fΓΌr den gesamten Sync-Lebenszyklus + * + * Deckt alle Phasen ab: + * - PREPARING: Indeterminate Spinner + "Synchronisiere…" (sofort beim Klick, bleibt bis echte Arbeit) + * - UPLOADING / DOWNLOADING / IMPORTING_MARKDOWN: Nur bei echten Aktionen + * - COMPLETED: Erfolgsmeldung mit Checkmark-Icon (auto-hide durch ComposeMainActivity) + * - ERROR: Fehlermeldung mit Error-Icon (auto-hide durch ComposeMainActivity) + * + * Silent Syncs (onResume) zeigen kein Banner (progress.isVisible == false) + */ +@Composable +fun SyncProgressBanner( + progress: SyncProgress, + modifier: Modifier = Modifier +) { + // Farbe animiert wechseln je nach State + val isError = progress.phase == SyncPhase.ERROR + val isCompleted = progress.phase == SyncPhase.COMPLETED + val isResult = isError || isCompleted + + val backgroundColor by animateColorAsState( + targetValue = when { + isError -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.primaryContainer + }, + label = "bannerColor" + ) + + val contentColor by animateColorAsState( + targetValue = when { + isError -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onPrimaryContainer + }, + label = "bannerContentColor" + ) + + AnimatedVisibility( + visible = progress.isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + modifier = modifier + ) { + Surface( + color = backgroundColor, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + // Zeile 1: Icon + Phase/Message + Counter + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Icon: Spinner (aktiv), Checkmark (completed), Error (error) + when { + isCompleted -> { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor + ) + } + isError -> { + Icon( + imageVector = Icons.Filled.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = contentColor + ) + } + else -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = contentColor + ) + } + } + + // Text: Ergebnisnachricht oder Phase + Text( + text = when { + isResult && !progress.resultMessage.isNullOrBlank() -> progress.resultMessage + else -> phaseToString(progress.phase) + }, + style = MaterialTheme.typography.bodyMedium, + color = contentColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Counter: x/y bei Uploads (Total bekannt), nur ZΓ€hler bei Downloads + if (!isResult && progress.current > 0) { + Text( + text = if (progress.total > 0) { + "${progress.current}/${progress.total}" + } else { + "${progress.current}" + }, + style = MaterialTheme.typography.labelMedium, + color = contentColor.copy(alpha = 0.7f) + ) + } + } + + // Zeile 2: Progress Bar (nur bei Upload mit bekanntem Total) + if (!isResult && progress.total > 0 && progress.current > 0 && + progress.phase == SyncPhase.UPLOADING) { + Spacer(modifier = Modifier.height(8.dp)) + + LinearProgressIndicator( + progress = { progress.progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = contentColor, + trackColor = contentColor.copy(alpha = 0.2f) + ) + } + + // Zeile 3: Aktueller Notiz-Titel (optional, nur bei aktivem Sync) + if (!isResult && !progress.currentFileName.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = progress.currentFileName, + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +/** + * Konvertiert SyncPhase zu lokalisierten String + */ +@Composable +private fun phaseToString(phase: SyncPhase): String { + return when (phase) { + SyncPhase.IDLE -> "" + SyncPhase.PREPARING -> stringResource(R.string.sync_phase_preparing) + SyncPhase.UPLOADING -> stringResource(R.string.sync_phase_uploading) + SyncPhase.DOWNLOADING -> stringResource(R.string.sync_phase_downloading) + SyncPhase.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown) + SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed) + SyncPhase.ERROR -> stringResource(R.string.sync_phase_error) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt index ac9ba26..8eae250 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/SyncStatusBanner.kt @@ -25,6 +25,7 @@ import dev.dettmer.simplenotes.sync.SyncStateManager * Sync status banner shown below the toolbar during sync * v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen + * v1.8.0: Nur noch COMPLETED/ERROR States - SYNCING wird von SyncProgressBanner ΓΌbernommen */ @Composable fun SyncStatusBanner( @@ -32,10 +33,10 @@ fun SyncStatusBanner( message: String?, modifier: Modifier = Modifier ) { - // v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund) - // Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT) - val isVisible = syncState != SyncStateManager.SyncState.IDLE - && syncState != SyncStateManager.SyncState.SYNCING_SILENT + // v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner ΓΌbernommen) + // IDLE und SYNCING_SILENT werden ignoriert + val isVisible = syncState == SyncStateManager.SyncState.COMPLETED + || syncState == SyncStateManager.SyncState.ERROR AnimatedVisibility( visible = isVisible, @@ -50,23 +51,13 @@ fun SyncStatusBanner( .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - if (syncState == SyncStateManager.SyncState.SYNCING) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 3.dp, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.width(12.dp)) + // v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner ΓΌbernommen Text( text = when (syncState) { - SyncStateManager.SyncState.SYNCING -> stringResource(R.string.sync_status_syncing) - SyncStateManager.SyncState.SYNCING_SILENT -> "" // v1.5.0: Wird nicht angezeigt (isVisible = false) SyncStateManager.SyncState.COMPLETED -> message ?: stringResource(R.string.sync_status_completed) SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error) - SyncStateManager.SyncState.IDLE -> "" + else -> "" // SYNCING/IDLE/SYNCING_SILENT nicht mehr relevant }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 5fc2c87..893bf41 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -76,6 +76,16 @@ %d auf Server gelΓΆscht + + Synchronisiere… + PrΓΌfe Server… + Hochladen… + Herunterladen… + Markdown importieren… + Speichern… + Sync abgeschlossen + Sync fehlgeschlagen + @@ -242,6 +252,7 @@ βš™οΈ Erweitert πŸ’‘ Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird. + Sync funktioniert nur wenn WLAN verbunden ist Nach dem Speichern Sync sofort nach jeder Γ„nderung diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index adc780b..e8df3c7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -83,6 +83,16 @@ %d deleted on server + + Synchronizing… + Checking server… + Uploading… + Downloading… + Importing Markdown… + Saving… + Sync complete + Sync failed + @@ -249,6 +259,7 @@ βš™οΈ Advanced πŸ’‘ WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected. + Sync only works when WiFi is connected After Saving Sync immediately after each change