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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user