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
This commit is contained in:
inventory69
2026-02-09 10:38:47 +01:00
parent bf7a74ec30
commit df37d2a47c
10 changed files with 536 additions and 94 deletions

View File

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

View File

@@ -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> = _syncStatus
// Lock für Thread-Sicherheit
// 🆕 v1.8.0: Einziges Banner-System - SyncProgress
private val _syncProgress = MutableStateFlow(SyncProgress.IDLE)
val syncProgress: StateFlow<SyncProgress> = _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
}
}
}

View File

@@ -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<String, String?>()
@@ -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(

View File

@@ -219,28 +219,29 @@ 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)
}
// 🆕 v1.8.0: Auto-Hide via SyncProgress (einziges Banner-System)
lifecycleScope.launch {
SyncStateManager.syncProgress.collect { progress ->
@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)
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 */ }
}
}
}
}
private fun openNoteEditor(noteId: String?) {
cameFromEditor = true

View File

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

View File

@@ -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<SyncProgress> = SyncStateManager.syncProgress
// Intern: SyncState für PullToRefresh-Indikator
private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
private val _syncMessage = MutableStateFlow<String?>(null)
val syncMessage: StateFlow<String?> = _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<Application>().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) {

View File

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

View File

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

View File

@@ -76,6 +76,16 @@
<!-- 🆕 v1.8.0 (IMPL_022): Sync-Banner Löschungsanzahl -->
<string name="sync_deleted_on_server_count">%d auf Server gelöscht</string>
<!-- 🆕 v1.8.0 (IMPL_006): Sync-Phasen Strings -->
<string name="sync_phase_preparing">Synchronisiere…</string>
<string name="sync_phase_checking">Prüfe Server…</string>
<string name="sync_phase_uploading">Hochladen…</string>
<string name="sync_phase_downloading">Herunterladen…</string>
<string name="sync_phase_importing_markdown">Markdown importieren…</string>
<string name="sync_phase_saving">Speichern…</string>
<string name="sync_phase_completed">Sync abgeschlossen</string>
<string name="sync_phase_error">Sync fehlgeschlagen</string>
<!-- ============================= -->
<!-- DELETE DIALOGS -->
<!-- ============================= -->
@@ -242,6 +252,7 @@
<string name="sync_section_advanced">⚙️ Erweitert</string>
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
<string name="sync_wifi_only_error">Sync funktioniert nur wenn WLAN verbunden ist</string>
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>

View File

@@ -83,6 +83,16 @@
<!-- 🆕 v1.8.0 (IMPL_022): Sync banner deletion count -->
<string name="sync_deleted_on_server_count">%d deleted on server</string>
<!-- 🆕 v1.8.0 (IMPL_006): Sync phase strings -->
<string name="sync_phase_preparing">Synchronizing…</string>
<string name="sync_phase_checking">Checking server…</string>
<string name="sync_phase_uploading">Uploading…</string>
<string name="sync_phase_downloading">Downloading…</string>
<string name="sync_phase_importing_markdown">Importing Markdown…</string>
<string name="sync_phase_saving">Saving…</string>
<string name="sync_phase_completed">Sync complete</string>
<string name="sync_phase_error">Sync failed</string>
<!-- ============================= -->
<!-- DELETE DIALOGS -->
<!-- ============================= -->
@@ -249,6 +259,7 @@
<string name="sync_section_advanced">⚙️ Advanced</string>
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
<string name="sync_wifi_only_error">Sync only works when WiFi is connected</string>
<string name="sync_trigger_on_save_title">After Saving</string>
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>