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