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.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import dev.dettmer.simplenotes.utils.Logger 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.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. * SyncProgress (StateFlow) steuert den gesamten Sync-Lebenszyklus:
* Thread-safe Singleton mit LiveData für UI-Reaktivität. * PREPARING → [UPLOADING] → [DOWNLOADING] → [IMPORTING_MARKDOWN] → COMPLETED/ERROR → IDLE
*
* SyncStatus (LiveData) wird nur noch intern für Mutex/Silent-Tracking verwendet.
*/ */
object SyncStateManager { object SyncStateManager {
private const val TAG = "SyncStateManager" private const val TAG = "SyncStateManager"
/** /**
* Mögliche Sync-Zustände * Mögliche Sync-Zustände (intern für Mutex + PullToRefresh)
*/ */
enum class SyncState { enum class SyncState {
IDLE, // Kein Sync aktiv IDLE,
SYNCING, // Sync läuft gerade (Banner sichtbar) SYNCING,
SYNCING_SILENT, // v1.5.0: Sync läuft im Hintergrund (kein Banner, z.B. onResume) SYNCING_SILENT,
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen) COMPLETED,
ERROR // Sync fehlgeschlagen (kurz anzeigen) ERROR
} }
/** /**
* Detaillierte Sync-Informationen für UI * Interne Sync-Informationen (für Mutex-Management + Silent-Tracking)
*/ */
data class SyncStatus( data class SyncStatus(
val state: SyncState = SyncState.IDLE, val state: SyncState = SyncState.IDLE,
val message: String? = null, val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background" val source: String? = null,
val silent: Boolean = false, // v1.5.0: Wenn true, wird nach Completion kein Banner angezeigt val silent: Boolean = false,
val timestamp: Long = System.currentTimeMillis() val timestamp: Long = System.currentTimeMillis()
) )
// Private mutable LiveData // Intern: Mutex + PullToRefresh State
private val _syncStatus = MutableLiveData(SyncStatus()) private val _syncStatus = MutableLiveData(SyncStatus())
// Public immutable LiveData für Observer
val syncStatus: LiveData<SyncStatus> = _syncStatus 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() private val lock = Any()
/** /**
@@ -56,54 +63,63 @@ object SyncStateManager {
/** /**
* Versucht einen Sync zu starten. * Versucht einen Sync zu starten.
* @param source Quelle des Syncs (für Logging) * Bei silent=false: Setzt sofort PREPARING-Phase → Banner erscheint instant
* @param silent v1.5.0: Wenn true, wird kein Banner angezeigt (z.B. bei onResume Auto-Sync) * Bei silent=true: Setzt silent-Flag → kein Banner wird angezeigt
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/ */
fun tryStartSync(source: String, silent: Boolean = false): Boolean { fun tryStartSync(source: String, silent: Boolean = false): Boolean {
synchronized(lock) { synchronized(lock) {
if (isSyncing) { 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 return false
} }
val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING val syncState = if (silent) SyncState.SYNCING_SILENT else SyncState.SYNCING
Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)") Logger.d(TAG, "🔄 Starting sync from: $source (silent=$silent)")
_syncStatus.postValue( _syncStatus.postValue(
SyncStatus( SyncStatus(
state = syncState, state = syncState,
message = "Synchronisiere...",
source = source, 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 return true
} }
} }
/** /**
* Markiert Sync als erfolgreich abgeschlossen * 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) { fun markCompleted(message: String? = null) {
synchronized(lock) { synchronized(lock) {
val current = _syncStatus.value val current = _syncStatus.value
val currentSource = current?.source
val wasSilent = current?.silent == true val wasSilent = current?.silent == true
val currentSource = current?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)") Logger.d(TAG, "✅ Sync completed from: $currentSource (silent=$wasSilent)")
if (wasSilent) { if (wasSilent) {
// v1.5.0: Silent-Sync - direkt auf IDLE, kein Banner anzeigen // Silent-Sync: Direkt auf IDLE - kein Banner
_syncStatus.postValue(SyncStatus()) _syncStatus.postValue(SyncStatus())
_syncProgress.value = SyncProgress.IDLE
} else { } else {
// Normaler Sync - COMPLETED State anzeigen // Normaler Sync: COMPLETED mit Nachricht anzeigen
_syncStatus.postValue( _syncStatus.postValue(
SyncStatus( SyncStatus(state = SyncState.COMPLETED, message = message, source = currentSource)
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 * Markiert Sync als fehlgeschlagen
* Bei Silent-Sync: Fehler trotzdem anzeigen (wichtig für User)
*/ */
fun markError(errorMessage: String?) { fun markError(errorMessage: String?) {
synchronized(lock) { 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") Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
_syncStatus.postValue( _syncStatus.postValue(
SyncStatus( SyncStatus(state = SyncState.ERROR, message = errorMessage, source = currentSource)
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() { fun reset() {
synchronized(lock) { synchronized(lock) {
_syncStatus.postValue(SyncStatus()) _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) { synchronized(lock) {
val current = _syncStatus.value ?: return val current = _syncProgress.value
if (current.state == SyncState.SYNCING) { _syncProgress.value = current.copy(
_syncStatus.postValue(current.copy(message = message)) 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}") Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
return@withContext try { 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") Logger.d(TAG, "📍 Step 1: Getting Sardine client")
val sardine = try { val sardine = try {
@@ -640,11 +642,23 @@ class WebDavSyncService(private val context: Context) {
// Ensure notes-md/ directory exists (for Markdown export) // Ensure notes-md/ directory exists (for Markdown export)
ensureMarkdownDirectoryExists(sardine, serverUrl) ensureMarkdownDirectoryExists(sardine, serverUrl)
// 🆕 v1.8.0: Phase 2 - Uploading (Phase wird nur bei echten Uploads gesetzt)
Logger.d(TAG, "📍 Step 4: Uploading local notes") Logger.d(TAG, "📍 Step 4: Uploading local notes")
// Upload local notes // Upload local notes
try { try {
Logger.d(TAG, "⬆️ Uploading local notes...") 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 syncedCount += uploadedCount
Logger.d(TAG, "✅ Uploaded: $uploadedCount notes") Logger.d(TAG, "✅ Uploaded: $uploadedCount notes")
} catch (e: Exception) { } catch (e: Exception) {
@@ -653,6 +667,7 @@ class WebDavSyncService(private val context: Context) {
throw e throw e
} }
// 🆕 v1.8.0: Phase 3 - Downloading (Phase wird nur bei echten Downloads gesetzt)
Logger.d(TAG, "📍 Step 5: Downloading remote notes") Logger.d(TAG, "📍 Step 5: Downloading remote notes")
// Download remote notes // Download remote notes
var deletedOnServerCount = 0 // 🆕 v1.8.0 var deletedOnServerCount = 0 // 🆕 v1.8.0
@@ -661,7 +676,17 @@ class WebDavSyncService(private val context: Context) {
val downloadResult = downloadRemoteNotes( val downloadResult = downloadRemoteNotes(
sardine, sardine,
serverUrl, 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 syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount conflictCount += downloadResult.conflictCount
@@ -679,11 +704,15 @@ class WebDavSyncService(private val context: Context) {
} }
Logger.d(TAG, "📍 Step 6: Auto-import Markdown (if enabled)") Logger.d(TAG, "📍 Step 6: Auto-import Markdown (if enabled)")
// Auto-import Markdown files from server // Auto-import Markdown files from server
var markdownImportedCount = 0 var markdownImportedCount = 0
try { try {
val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
if (markdownAutoImportEnabled) { if (markdownAutoImportEnabled) {
// 🆕 v1.8.0: Phase nur setzen wenn Feature aktiv
SyncStateManager.updateProgress(phase = SyncPhase.IMPORTING_MARKDOWN)
Logger.d(TAG, "📥 Auto-importing Markdown files...") Logger.d(TAG, "📥 Auto-importing Markdown files...")
markdownImportedCount = importMarkdownFiles(sardine, serverUrl) markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files") 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") Logger.d(TAG, "📍 Step 7: Saving sync timestamp")
// Update last sync timestamp // Update last sync timestamp
try { try {
saveLastSyncTimestamp() saveLastSyncTimestamp()
@@ -732,6 +762,13 @@ class WebDavSyncService(private val context: Context) {
} }
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
// 🆕 v1.8.0: Phase 6 - Completed
SyncStateManager.updateProgress(
phase = SyncPhase.COMPLETED,
current = effectiveSyncedCount,
total = effectiveSyncedCount
)
SyncResult( SyncResult(
isSuccess = true, isSuccess = true,
syncedCount = effectiveSyncedCount, syncedCount = effectiveSyncedCount,
@@ -748,6 +785,9 @@ class WebDavSyncService(private val context: Context) {
e.printStackTrace() e.printStackTrace()
Logger.e(TAG, "═══════════════════════════════════════") Logger.e(TAG, "═══════════════════════════════════════")
// 🆕 v1.8.0: Phase ERROR
SyncStateManager.updateProgress(phase = SyncPhase.ERROR)
SyncResult( SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = when (e) { errorMessage = when (e) {
@@ -770,6 +810,8 @@ class WebDavSyncService(private val context: Context) {
} finally { } finally {
// ⚡ v1.3.1: Session-Caches leeren // ⚡ v1.3.1: Session-Caches leeren
clearSessionCache() clearSessionCache()
// 🆕 v1.8.0: Reset progress state
SyncStateManager.resetProgress()
// 🔒 v1.3.1: Sync-Mutex freigeben // 🔒 v1.3.1: Sync-Mutex freigeben
syncMutex.unlock() syncMutex.unlock()
} }
@@ -777,11 +819,21 @@ class WebDavSyncService(private val context: Context) {
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and state management // 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 var uploadedCount = 0
val localNotes = storage.loadAllNotes() val localNotes = storage.loadAllNotes()
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) 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 // 🔧 v1.7.2 (IMPL_004): Batch E-Tag Updates für Performance
val etagUpdates = mutableMapOf<String, String?>() val etagUpdates = mutableMapOf<String, String?>()
@@ -805,6 +857,9 @@ class WebDavSyncService(private val context: Context) {
storage.saveNote(noteToUpload) storage.saveNote(noteToUpload)
uploadedCount++ 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.3.1: Refresh E-Tag after upload to prevent re-download
// 🔧 v1.7.2 (IMPL_004): Sammle E-Tags für Batch-Update // 🔧 v1.7.2 (IMPL_004): Sammle E-Tags für Batch-Update
try { try {
@@ -1107,7 +1162,8 @@ class WebDavSyncService(private val context: Context) {
serverUrl: String, serverUrl: String,
includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server includeRootFallback: Boolean = false, // 🆕 v1.2.2: Only for restore from server
forceOverwrite: Boolean = false, // 🆕 v1.3.0: For OVERWRITE_DUPLICATES mode 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 { ): DownloadResult {
var downloadedCount = 0 var downloadedCount = 0
var conflictCount = 0 var conflictCount = 0
@@ -1145,7 +1201,7 @@ class WebDavSyncService(private val context: Context) {
serverNoteIds.add(noteId) serverNoteIds.add(noteId)
} }
for (resource in jsonFiles) { for ((index, resource) in jsonFiles.withIndex()) {
val noteId = resource.name.removeSuffix(".json") val noteId = resource.name.removeSuffix(".json")
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
@@ -1235,6 +1291,8 @@ class WebDavSyncService(private val context: Context) {
// New note from server // New note from server
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ 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}") Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync // ⚡ Cache E-Tag for next sync
@@ -1246,6 +1304,7 @@ class WebDavSyncService(private val context: Context) {
// OVERWRITE mode: Always replace regardless of timestamps // OVERWRITE mode: Always replace regardless of timestamps
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ downloadedCount++
onProgress(downloadedCount, 0, remoteNote.title)
Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync // ⚡ Cache E-Tag for next sync
@@ -1259,10 +1318,12 @@ class WebDavSyncService(private val context: Context) {
// Conflict detected // Conflict detected
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
conflictCount++ conflictCount++
// 🆕 v1.8.0: Conflict zählt nicht als Download
} else { } else {
// Safe to overwrite // Safe to overwrite
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++ downloadedCount++
onProgress(downloadedCount, 0, remoteNote.title)
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}") Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
// ⚡ Cache E-Tag for next sync // ⚡ 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( Logger.d(

View File

@@ -219,28 +219,29 @@ class ComposeMainActivity : ComponentActivity() {
} }
private fun setupSyncStateObserver() { private fun setupSyncStateObserver() {
// 🆕 v1.8.0: SyncStatus nur noch für PullToRefresh-Indikator (intern)
SyncStateManager.syncStatus.observe(this) { status -> SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(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 @Suppress("MagicNumber") // UI timing delays for banner visibility
// Hide banner after delay for completed/error states when (progress.phase) {
when (status.state) { dev.dettmer.simplenotes.sync.SyncPhase.COMPLETED -> {
SyncStateManager.SyncState.COMPLETED -> { kotlinx.coroutines.delay(2000L)
lifecycleScope.launch {
kotlinx.coroutines.delay(1500L)
SyncStateManager.reset() SyncStateManager.reset()
} }
} dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
SyncStateManager.SyncState.ERROR -> { kotlinx.coroutines.delay(4000L)
lifecycleScope.launch {
kotlinx.coroutines.delay(3000L)
SyncStateManager.reset() SyncStateManager.reset()
} }
}
else -> { /* No action needed */ } else -> { /* No action needed */ }
} }
} }
} }
}
private fun openNoteEditor(noteId: String?) { private fun openNoteEditor(noteId: String?) {
cameFromEditor = true cameFromEditor = true

View File

@@ -8,6 +8,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState 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.NoteTypeFAB
import dev.dettmer.simplenotes.ui.main.components.NotesList import dev.dettmer.simplenotes.ui.main.components.NotesList
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid 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 dev.dettmer.simplenotes.ui.main.components.SyncStatusLegendDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -78,9 +79,11 @@ fun MainScreen(
) { ) {
val notes by viewModel.notes.collectAsState() val notes by viewModel.notes.collectAsState()
val syncState by viewModel.syncState.collectAsState() val syncState by viewModel.syncState.collectAsState()
val syncMessage by viewModel.syncMessage.collectAsState()
val scrollToTop by viewModel.scrollToTop.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState()
// 🆕 v1.8.0: Einziges Banner-System
val syncProgress by viewModel.syncProgress.collectAsState()
// Multi-Select State // Multi-Select State
val selectedNotes by viewModel.selectedNotes.collectAsState() val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
@@ -197,10 +200,10 @@ fun MainScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Main content column // Main content column
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Sync Status Banner (not affected by pull-to-refresh) // 🆕 v1.8.0: Einziges Sync Banner (Progress + Ergebnis)
SyncStatusBanner( SyncProgressBanner(
syncState = syncState, progress = syncProgress,
message = syncMessage modifier = Modifier.fillMaxWidth()
) )
// Content: Empty state or notes list // 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.models.Note
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncProgress
import dev.dettmer.simplenotes.sync.SyncStateManager import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants 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) private val _syncState = MutableStateFlow(SyncStateManager.SyncState.IDLE)
val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow() val syncState: StateFlow<SyncStateManager.SyncState> = _syncState.asStateFlow()
private val _syncMessage = MutableStateFlow<String?>(null)
val syncMessage: StateFlow<String?> = _syncMessage.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// UI Events // UI Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -495,12 +497,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun updateSyncState(status: SyncStateManager.SyncStatus) { fun updateSyncState(status: SyncStateManager.SyncStatus) {
_syncState.value = status.state _syncState.value = status.state
_syncMessage.value = status.message
} }
/** /**
* Trigger manual sync (from toolbar button or pull-to-refresh) * Trigger manual sync (from toolbar button or pull-to-refresh)
* v1.7.0: Uses central canSync() gate for WiFi-only check * 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") { fun triggerManualSync(source: String = "manual") {
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config) // 🆕 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.canSync) {
if (gateResult.isBlockedByWifiOnly) { if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi") 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 { } else {
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}") 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.7.0: Feedback wenn Sync bereits läuft
// 🆕 v1.8.0: tryStartSync setzt sofort PREPARING → Banner erscheint instant
if (!SyncStateManager.tryStartSync(source)) { if (!SyncStateManager.tryStartSync(source)) {
if (SyncStateManager.isSyncing) { if (SyncStateManager.isSyncing) {
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress") Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
@@ -533,11 +536,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch { viewModelScope.launch {
try { try {
// Check for unsynced changes // Check for unsynced changes (Banner zeigt bereits PREPARING)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
val message = getApplication<Application>().getString(R.string.toast_already_synced) SyncStateManager.markCompleted(getString(R.string.toast_already_synced))
SyncStateManager.markCompleted(message)
loadNotes() loadNotes()
return@launch return@launch
} }
@@ -614,7 +616,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return 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)) { if (!SyncStateManager.tryStartSync("auto-$source", silent = true)) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress") Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return return
@@ -630,7 +633,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Check for unsynced changes // Check for unsynced changes
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset() SyncStateManager.reset() // Silent → geht direkt auf IDLE
return@launch return@launch
} }
@@ -641,7 +644,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset() SyncStateManager.reset() // Silent → kein Error-Banner
return@launch return@launch
} }
@@ -652,14 +655,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") 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)) SyncStateManager.markCompleted(getString(R.string.toast_sync_success, result.syncedCount))
_showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount)) _showToast.emit(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") 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 { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } 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 * Sync status banner shown below the toolbar during sync
* v1.5.0: Jetpack Compose MainActivity Redesign * v1.5.0: Jetpack Compose MainActivity Redesign
* v1.5.0: SYNCING_SILENT ignorieren - Banner nur bei manuellen Syncs oder Fehlern anzeigen * 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 @Composable
fun SyncStatusBanner( fun SyncStatusBanner(
@@ -32,10 +33,10 @@ fun SyncStatusBanner(
message: String?, message: String?,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// v1.5.0: Banner nicht anzeigen bei IDLE oder SYNCING_SILENT (Auto-Sync im Hintergrund) // v1.8.0: Nur COMPLETED/ERROR anzeigen (SYNCING wird von SyncProgressBanner übernommen)
// Fehler werden trotzdem angezeigt (ERROR state nach Silent-Sync wechselt zu ERROR, nicht SYNCING_SILENT) // IDLE und SYNCING_SILENT werden ignoriert
val isVisible = syncState != SyncStateManager.SyncState.IDLE val isVisible = syncState == SyncStateManager.SyncState.COMPLETED
&& syncState != SyncStateManager.SyncState.SYNCING_SILENT || syncState == SyncStateManager.SyncState.ERROR
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = isVisible,
@@ -50,23 +51,13 @@ fun SyncStatusBanner(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (syncState == SyncStateManager.SyncState.SYNCING) { // v1.8.0: Kein Loading-Icon mehr - wird von SyncProgressBanner übernommen
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = when (syncState) { 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.COMPLETED -> message ?: stringResource(R.string.sync_status_completed)
SyncStateManager.SyncState.ERROR -> message ?: stringResource(R.string.sync_status_error) 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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.onPrimaryContainer,

View File

@@ -76,6 +76,16 @@
<!-- 🆕 v1.8.0 (IMPL_022): Sync-Banner Löschungsanzahl --> <!-- 🆕 v1.8.0 (IMPL_022): Sync-Banner Löschungsanzahl -->
<string name="sync_deleted_on_server_count">%d auf Server gelöscht</string> <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 --> <!-- DELETE DIALOGS -->
<!-- ============================= --> <!-- ============================= -->
@@ -242,6 +252,7 @@
<string name="sync_section_advanced">⚙️ Erweitert</string> <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_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_title">Nach dem Speichern</string>
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</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 --> <!-- 🆕 v1.8.0 (IMPL_022): Sync banner deletion count -->
<string name="sync_deleted_on_server_count">%d deleted on server</string> <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 --> <!-- DELETE DIALOGS -->
<!-- ============================= --> <!-- ============================= -->
@@ -249,6 +259,7 @@
<string name="sync_section_advanced">⚙️ Advanced</string> <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_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_title">After Saving</string>
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string> <string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>