diff --git a/.gitignore b/.gitignore
index c25c326..3bc6630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,3 +43,8 @@ Thumbs.db
*.swp
*~
test-apks/
+
+# F-Droid metadata (managed in fdroiddata repo)
+# Exclude fastlane metadata (we want to track those screenshots)
+metadata/
+!fastlane/metadata/
diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md
index aa2ce0f..fb7e63e 100644
--- a/CHANGELOG.de.md
+++ b/CHANGELOG.de.md
@@ -8,6 +8,54 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
+## [1.6.0] - 2026-01-19
+
+### π Major: Konfigurierbare Sync-Trigger
+
+Feingranulare Kontrolle darΓΌber, wann deine Notizen synchronisiert werden - wΓ€hle die Trigger, die am besten zu deinem Workflow passen!
+
+### βοΈ Sync-Trigger System
+
+- **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln in den Einstellungen aktivieren/deaktivieren
+- **5 UnabhΓ€ngige Trigger:**
+ - **onSave Sync** - Sync sofort nach dem Speichern einer Notiz (5s Throttle)
+ - **onResume Sync** - Sync beim Γffnen der App (60s Throttle)
+ - **WiFi-Connect Sync** - Sync bei WiFi-Verbindung
+ - **Periodischer Sync** - Hintergrund-Sync alle 15/30/60 Minuten (konfigurierbar)
+ - **Boot Sync** - Startet Hintergrund-Sync nach GerΓ€teneustart
+
+- **Smarte Defaults** - Nur ereignisbasierte Trigger standardmΓ€Γig aktiv (onSave, onResume, WiFi-Connect)
+- **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit aktiviertem periodischen Sync
+- **Offline-Modus UI** - Ausgegraute Sync-Toggles wenn kein Server konfiguriert
+- **Dynamischer Settings-Subtitle** - Zeigt Anzahl aktiver Trigger im Haupteinstellungs-Screen
+
+### π§ Server-Konfiguration Verbesserungen
+
+- **Offline-Modus Toggle** - Alle Netzwerkfunktionen mit einem Schalter deaktivieren
+- **Getrennte Protokoll & Host Eingabe** - Protokoll (http/https) als nicht-editierbares PrΓ€fix angezeigt
+- **Klickbare Settings-Cards** - Gesamte Card klickbar fΓΌr bessere UX
+- **Klickbare Toggle-Zeilen** - Text/Icon klicken um Switches zu bedienen (nicht nur der Switch selbst)
+
+### π Bug Fixes
+
+- **Fix:** Fehlender 5. Sync-Trigger (Boot) in der Haupteinstellungs-Screen Subtitle-ZΓ€hlung
+- **Fix:** Offline-Modus Status wird nicht aktualisiert beim ZurΓΌckkehren aus Einstellungen
+- **Fix:** Pull-to-Refresh funktioniert auch im Offline-Modus
+
+### π§ Technische Verbesserungen
+
+- **Reaktiver Offline-Modus Status** - StateFlow stellt sicher, dass UI korrekt aktualisiert wird
+- **Getrennte Server-Config Checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
+- **Verbesserte Konstanten** - Alle Sync-Trigger Keys und Defaults in Constants.kt
+- **Bessere Code-Organisation** - Settings-Screens fΓΌr Klarheit refactored
+
+### Looking Ahead
+
+> π **v1.7.0** wird Server-Ordner PrΓΌfung und weitere Community-Features bringen.
+> Feature-Requests sind willkommen als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
+
+---
+
## [1.5.0] - 2026-01-15
### π Major: Jetpack Compose UI Redesign
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3621b5..e6dd0cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
+## [1.6.0] - 2026-01-19
+
+### π Major: Configurable Sync Triggers
+
+Fine-grained control over when your notes sync - choose which triggers fit your workflow best!
+
+### βοΈ Sync Trigger System
+
+- **Individual trigger control** - Enable/disable each sync trigger separately in settings
+- **5 Independent Triggers:**
+ - **onSave Sync** - Sync immediately after saving a note (5s throttle)
+ - **onResume Sync** - Sync when app is opened (60s throttle)
+ - **WiFi-Connect Sync** - Sync when WiFi is connected
+ - **Periodic Sync** - Background sync every 15/30/60 minutes (configurable)
+ - **Boot Sync** - Start background sync after device restart
+
+- **Smart Defaults** - Only event-driven triggers active by default (onSave, onResume, WiFi-Connect)
+- **Battery Optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic sync enabled
+- **Offline Mode UI** - Grayed-out sync toggles when no server configured
+- **Dynamic Settings Subtitle** - Shows count of active triggers on main settings screen
+
+### π§ Server Configuration Improvements
+
+- **Offline Mode Toggle** - Disable all network features with one switch
+- **Split Protocol & Host** - Protocol (http/https) shown as non-editable prefix
+- **Clickable Settings Cards** - Full card clickable for better UX
+- **Clickable Toggle Rows** - Click text/icon to toggle switches (not just the switch itself)
+
+### π Bug Fixes
+
+- **Fixed:** Missing 5th sync trigger (Boot) in main settings screen subtitle count
+- **Various fixes** - UI improvements and stability enhancements
+
+### π§ Technical Improvements
+
+- **Reactive offline mode state** - StateFlow ensures UI updates correctly
+- **Separated server config checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
+- **Improved constants** - All sync trigger keys and defaults in Constants.kt
+- **Better code organization** - Settings screens refactored for clarity
+
+### Looking Ahead
+
+> π **v1.7.0** will bring server folder checking and additional community features.
+> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
+
+---
+
## [1.5.0] - 2026-01-15
### π Major: Jetpack Compose UI Redesign
diff --git a/README.de.md b/README.de.md
index 1fe3111..a6889b6 100644
--- a/README.de.md
+++ b/README.de.md
@@ -18,12 +18,12 @@
## π± Screenshots
-
+
-
+
---
@@ -33,11 +33,11 @@
- β
**NEU: Checklisten** - Tap-to-Check, Drag & Drop
- π **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
- π **Offline-First** - Funktioniert ohne Internet
-- π **Auto-Sync** - WLAN-Verbindung, regelmΓ€Γige Intervalle (15/30/60 Min) & Multi-GerΓ€te-Sync
+- π **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot
- π **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- πΎ **Lokales Backup** - Export/Import als JSON-Datei
- π₯οΈ **Desktop-Integration** - Markdown-Export fΓΌr Obsidian, VS Code, Typora
-- π **Akkuschonend** - ~0.2-0.8% pro Tag
+- π **Akkuschonend** - ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
- π¨ **Material Design 3** - Dark Mode & Dynamic Colors
β‘οΈ **VollstΓ€ndige Feature-Liste:** [FEATURES.de.md](docs/FEATURES.de.md)
@@ -112,4 +112,4 @@ MIT License - siehe [LICENSE](LICENSE)
---
-**v1.4.1** Β· Built with β€οΈ using Kotlin + Material Design 3
+**v1.6.0** Β· Built with β€οΈ using Kotlin + Material Design 3
diff --git a/README.md b/README.md
index 0e82781..331b5b2 100644
--- a/README.md
+++ b/README.md
@@ -18,12 +18,12 @@
## π± Screenshots
-
+
-
+
---
@@ -33,11 +33,11 @@
- β
**NEW: Checklists** - Tap-to-check, drag & drop
- π **NEW: Multilingual** - English/German with language selector
- π **Offline-first** - Works without internet
-- π **Auto-sync** - WiFi reconnect, periodic intervals (15/30/60 min) & multi-device sync
+- π **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
- π **Self-hosted** - Your data stays with you (WebDAV)
- πΎ **Local backup** - Export/Import as JSON file
- π₯οΈ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
-- π **Battery-friendly** - ~0.2-0.8% per day
+- π **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
- π¨ **Material Design 3** - Dark mode & dynamic colors
β‘οΈ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
@@ -108,4 +108,4 @@ MIT License - see [LICENSE](LICENSE)
---
-**v1.5.0** Β· Built with β€οΈ using Kotlin + Jetpack Compose + Material Design 3
+**v1.6.0** Β· Built with β€οΈ using Kotlin + Jetpack Compose + Material Design 3
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2115304..cc0d904 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
- versionCode = 13 // π§ v1.5.0: Jetpack Compose Settings Redesign
- versionName = "1.5.0" // π§ v1.5.0: Jetpack Compose Settings Redesign
+ versionCode = 14 // π§ v1.6.0: Configurable Sync Triggers
+ versionName = "1.6.0" // π§ v1.6.0: Configurable Sync Triggers
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/BootReceiver.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/BootReceiver.kt
index bc13ced..c4e5758 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/BootReceiver.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/BootReceiver.kt
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
/**
* BootReceiver: Startet WorkManager nach Device Reboot
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
+ * v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
*/
class BootReceiver : BroadcastReceiver() {
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
Logger.d(TAG, "π± BOOT_COMPLETED received")
- // PrΓΌfe ob Auto-Sync aktiviert ist
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
- val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
- if (!autoSyncEnabled) {
- Logger.d(TAG, "β Auto-sync disabled - not starting WorkManager")
+ // π v1.6.0: Check if Boot trigger is enabled
+ if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
+ Logger.d(TAG, "βοΈ Boot sync disabled - not starting WorkManager")
return
}
- Logger.d(TAG, "π Auto-sync enabled - starting WorkManager")
+ // Check if server is configured
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ Logger.d(TAG, "βοΈ Offline mode - not starting WorkManager")
+ return
+ }
+
+ Logger.d(TAG, "π Boot sync enabled - starting WorkManager")
// WorkManager neu starten
val networkMonitor = NetworkMonitor(context.applicationContext)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
index 2f3b54e..f20bfbe 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
@@ -102,8 +102,22 @@ class NetworkMonitor(private val context: Context) {
/**
* Triggert WiFi-Connect Sync via WorkManager
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
+ * v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
*/
private fun triggerWifiConnectSync() {
+ // π v1.6.0: Check if WiFi-Connect trigger is enabled
+ if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
+ Logger.d(TAG, "βοΈ WiFi-Connect sync disabled - skipping")
+ return
+ }
+
+ // Check if server is configured
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ Logger.d(TAG, "βοΈ Offline mode - skipping WiFi-Connect sync")
+ return
+ }
+
Logger.d(TAG, "π‘ Scheduling WiFi-Connect sync via WorkManager")
// π₯ WICHTIG: NetworkType.UNMETERED constraint!
@@ -148,8 +162,25 @@ class NetworkMonitor(private val context: Context) {
/**
* Startet WorkManager periodic sync
* π₯ Interval aus SharedPrefs konfigurierbar (15/30/60 min)
+ * v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
*/
private fun startPeriodicSync() {
+ // π v1.6.0: Check if Periodic trigger is enabled
+ if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
+ Logger.d(TAG, "βοΈ Periodic sync disabled - skipping")
+ // Cancel existing periodic work if disabled
+ WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
+ return
+ }
+
+ // Check if server is configured
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ Logger.d(TAG, "βοΈ Offline mode - skipping Periodic sync")
+ WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
+ return
+ }
+
// π₯ Interval aus SharedPrefs lesen
val intervalMinutes = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES,
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt
index f382e23..6d64d0f 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorScreen.kt
@@ -76,6 +76,9 @@ fun NoteEditorScreen(
val uiState by viewModel.uiState.collectAsState()
val checklistItems by viewModel.checklistItems.collectAsState()
+ // π v1.6.0: Offline mode state
+ val isOfflineMode by viewModel.isOfflineMode.collectAsState()
+
var showDeleteDialog by remember { mutableStateOf(false) }
var focusNewItemId by remember { mutableStateOf(null) }
val scope = rememberCoroutineScope()
@@ -233,6 +236,7 @@ fun NoteEditorScreen(
if (showDeleteDialog) {
DeleteConfirmationDialog(
noteCount = 1,
+ isOfflineMode = isOfflineMode,
onDismiss = { showDeleteDialog = false },
onDeleteLocal = {
showDeleteDialog = false
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
index 489a068..be5ab2c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt
@@ -1,15 +1,20 @@
package dev.dettmer.simplenotes.ui.editor
import android.app.Application
+import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
+import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService
+import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
@@ -42,6 +47,7 @@ class NoteEditorViewModel(
}
private val storage = NotesStorage(application)
+ private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// State
@@ -53,6 +59,12 @@ class NoteEditorViewModel(
private val _checklistItems = MutableStateFlow>(emptyList())
val checklistItems: StateFlow> = _checklistItems.asStateFlow()
+ // π v1.6.0: Offline Mode State
+ private val _isOfflineMode = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
+ )
+ val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow()
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Events
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -284,6 +296,10 @@ class NoteEditorViewModel(
}
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
+
+ // π v1.6.0: Trigger onSave Sync
+ triggerOnSaveSync()
+
_events.emit(NoteEditorEvent.NavigateBack)
}
}
@@ -331,6 +347,52 @@ class NoteEditorViewModel(
}
fun canDelete(): Boolean = existingNote != null
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // π v1.6.0: Sync Trigger - onSave
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ /**
+ * Triggers sync after saving a note (if enabled and server configured)
+ * v1.6.0: New configurable sync trigger
+ *
+ * Separate throttling (5 seconds) to prevent spam when saving multiple times
+ */
+ private fun triggerOnSaveSync() {
+ // Check 1: Trigger enabled?
+ if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)) {
+ Logger.d(TAG, "βοΈ onSave sync disabled - skipping")
+ return
+ }
+
+ // Check 2: Server configured?
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ Logger.d(TAG, "βοΈ Offline mode - skipping onSave sync")
+ return
+ }
+
+ // Check 3: Throttling (5 seconds) to prevent spam
+ val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
+ val now = System.currentTimeMillis()
+ val timeSinceLastSync = now - lastOnSaveSyncTime
+
+ if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
+ val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
+ Logger.d(TAG, "β³ onSave sync throttled - wait ${remainingSeconds}s")
+ return
+ }
+
+ // Update last sync time
+ prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
+
+ // Trigger sync via WorkManager
+ Logger.d(TAG, "π€ Triggering onSave sync")
+ val syncRequest = OneTimeWorkRequestBuilder()
+ .addTag(Constants.SYNC_WORK_TAG)
+ .build()
+ WorkManager.getInstance(getApplication()).enqueue(syncRequest)
+ }
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
index 5af51b1..cc172a7 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/ComposeMainActivity.kt
@@ -177,6 +177,10 @@ class ComposeMainActivity : ComponentActivity() {
Logger.d(TAG, "π± ComposeMainActivity.onResume() - Registering receivers")
+ // π v1.6.0: Refresh offline mode state FIRST (before any sync checks)
+ // This ensures UI reflects current offline mode when returning from Settings
+ viewModel.refreshOfflineModeState()
+
// Register BroadcastReceiver for Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt
index a3a3dc5..9c4b528 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainScreen.kt
@@ -79,6 +79,9 @@ fun MainScreen(
val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
+ // π v1.6.0: Reactive offline mode state
+ val isOfflineMode by viewModel.isOfflineMode.collectAsState()
+
// Delete confirmation dialog state
var showBatchDeleteDialog by remember { mutableStateOf(false) }
@@ -89,6 +92,13 @@ fun MainScreen(
// Compute isSyncing once
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
+ // π v1.6.0: Reactive sync availability (recomposes when offline mode changes)
+ // Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
+ // which is called in ComposeMainActivity.onResume() when returning from Settings
+ val hasServerConfig = viewModel.hasServerConfig()
+ val isSyncAvailable = !isOfflineMode && hasServerConfig
+ val canSync = isSyncAvailable && !isSyncing
+
// Handle snackbar events from ViewModel
LaunchedEffect(Unit) {
viewModel.showSnackbar.collect { data ->
@@ -136,7 +146,7 @@ fun MainScreen(
exit = slideOutVertically() + fadeOut()
) {
MainTopBar(
- syncEnabled = !isSyncing,
+ syncEnabled = canSync,
onSyncClick = { viewModel.triggerManualSync("toolbar") },
onSettingsClick = onOpenSettings
)
@@ -146,10 +156,10 @@ fun MainScreen(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface
) { paddingValues ->
- // PullToRefreshBox wraps the content with pull-to-refresh capability
+ // π v1.6.0: PullToRefreshBox only enabled when sync available
PullToRefreshBox(
isRefreshing = isSyncing,
- onRefresh = { viewModel.triggerManualSync("pullToRefresh") },
+ onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
@@ -207,6 +217,7 @@ fun MainScreen(
if (showBatchDeleteDialog) {
DeleteConfirmationDialog(
noteCount = selectedNotes.size,
+ isOfflineMode = isOfflineMode,
onDismiss = { showBatchDeleteDialog = false },
onDeleteLocal = {
viewModel.deleteSelectedNotes(deleteFromServer = false)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
index 3e6ed1c..149e90d 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt
@@ -62,6 +62,26 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // π v1.6.0: Offline Mode State (reactive)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ private val _isOfflineMode = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
+ )
+ val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow()
+
+ /**
+ * Refresh offline mode state from SharedPreferences
+ * Called when returning from Settings screen (in onResume)
+ */
+ fun refreshOfflineModeState() {
+ val oldValue = _isOfflineMode.value
+ val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
+ _isOfflineMode.value = newValue
+ Logger.d(TAG, "π refreshOfflineModeState: offlineMode=$oldValue β $newValue")
+ }
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Sync State (derived from SyncStateManager)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -460,6 +480,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Trigger manual sync (from toolbar button or pull-to-refresh)
*/
fun triggerManualSync(source: String = "manual") {
+ // π v1.6.0: Block sync in offline mode
+ if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
+ Logger.d(TAG, "βοΈ $source Sync blocked: Offline mode enabled")
+ return
+ }
+
if (!SyncStateManager.tryStartSync(source)) {
return
}
@@ -513,8 +539,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Trigger auto-sync (onResume)
* Only runs if server is configured and interval has passed
* v1.5.0: Silent-Sync - kein Banner wΓ€hrend des Syncs, Fehler werden trotzdem angezeigt
+ * v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
*/
fun triggerAutoSync(source: String = "auto") {
+ // π v1.6.0: Check if onResume trigger is enabled
+ if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
+ Logger.d(TAG, "βοΈ onResume sync disabled - skipping")
+ return
+ }
+
// Throttling check
if (!canTriggerAutoSync()) {
return
@@ -523,6 +556,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ Logger.d(TAG, "βοΈ Offline mode - skipping onResume sync")
return
}
@@ -607,6 +641,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
getApplication().getString(resId, *formatArgs)
fun isServerConfigured(): Boolean {
+ // π v1.6.0: Use reactive offline mode state
+ if (_isOfflineMode.value) {
+ return false
+ }
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
+ }
+
+ /**
+ * π v1.6.0: Check if server has a configured URL (ignores offline mode)
+ * Used for determining if sync would be available when offline mode is disabled
+ */
+ fun hasServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt
index 6029aae..390954f 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt
@@ -2,15 +2,24 @@ package dev.dettmer.simplenotes.ui.main.components
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.CloudOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -19,10 +28,12 @@ import dev.dettmer.simplenotes.R
/**
* Delete confirmation dialog with server/local options
* v1.5.0: Multi-Select Feature
+ * v1.6.0: Offline mode support - disables server deletion option
*/
@Composable
fun DeleteConfirmationDialog(
noteCount: Int = 1,
+ isOfflineMode: Boolean = false,
onDismiss: () -> Unit,
onDeleteLocal: () -> Unit,
onDeleteEverywhere: () -> Unit
@@ -59,16 +70,56 @@ fun DeleteConfirmationDialog(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Delete everywhere (server + local) - primary action
+ // π v1.6.0: Disabled in offline mode with visual hint
TextButton(
onClick = onDeleteEverywhere,
modifier = Modifier.fillMaxWidth(),
+ enabled = !isOfflineMode,
colors = ButtonDefaults.textButtonColors(
- contentColor = MaterialTheme.colorScheme.error
+ contentColor = MaterialTheme.colorScheme.error,
+ disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
) {
Text(stringResource(R.string.delete_everywhere))
}
+ // π v1.6.0: Show offline hint in a subtle Surface container
+ if (isOfflineMode) {
+ Surface(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(horizontal = 8.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloudOff,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = stringResource(R.string.delete_everywhere_offline_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+
// Delete local only
TextButton(
onClick = onDeleteLocal,
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt
index 18c1c0e..4c18631 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt
@@ -55,7 +55,13 @@ fun SettingsNavHost(
composable(SettingsRoute.Sync.route) {
SyncSettingsScreen(
viewModel = viewModel,
- onBack = { navController.popBackStack() }
+ onBack = { navController.popBackStack() },
+ onNavigateToServerSettings = {
+ navController.navigate(SettingsRoute.Server.route) {
+ // Avoid multiple copies of server settings in back stack
+ launchSingleTop = true
+ }
+ }
)
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
index 4f10f5c..2813dce 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt
@@ -16,9 +16,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
@@ -46,10 +49,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
- private val initialUrl = if (storedUrl.isEmpty()) "http://" else storedUrl
- private val _serverUrl = MutableStateFlow(initialUrl)
- val serverUrl: StateFlow = _serverUrl.asStateFlow()
+ // π v1.6.0: Separate host from prefix for better UX
+ // isHttps determines the prefix, serverHost is the editable part
+ private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
+ val isHttps: StateFlow = _isHttps.asStateFlow()
+
+ // Extract host part (everything after http:// or https://)
+ private fun extractHostFromUrl(url: String): String {
+ return when {
+ url.startsWith("https://") -> url.removePrefix("https://")
+ url.startsWith("http://") -> url.removePrefix("http://")
+ else -> url
+ }
+ }
+
+ // π v1.6.0: Only the host part is editable (without protocol prefix)
+ private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
+ val serverHost: StateFlow = _serverHost.asStateFlow()
+
+ // π v1.6.0: Full URL for display purposes (computed from prefix + host)
+ val serverUrl: StateFlow = combine(_isHttps, _serverHost) { https, host ->
+ val prefix = if (https) "https://" else "http://"
+ if (host.isEmpty()) "" else prefix + host
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow = _username.asStateFlow()
@@ -57,13 +80,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow = _password.asStateFlow()
- // v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty)
- private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
- val isHttps: StateFlow = _isHttps.asStateFlow()
-
private val _serverStatus = MutableStateFlow(ServerStatus.Unknown)
val serverStatus: StateFlow = _serverStatus.asStateFlow()
+ // π v1.6.0: Offline Mode Toggle
+ // Default: true for new users (no server), false for existing users (has server config)
+ private val _offlineMode = MutableStateFlow(
+ if (prefs.contains(Constants.KEY_OFFLINE_MODE)) {
+ prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
+ } else {
+ // Migration: auto-detect based on existing server config
+ !hasExistingServerConfig()
+ }
+ )
+ val offlineMode: StateFlow = _offlineMode.asStateFlow()
+
+ private fun hasExistingServerConfig(): Boolean {
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ return !serverUrl.isNullOrEmpty() &&
+ serverUrl != "http://" &&
+ serverUrl != "https://"
+ }
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Events (for Activity-level actions like dialogs, intents)
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -90,6 +128,32 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
)
val syncInterval: StateFlow = _syncInterval.asStateFlow()
+ // π v1.6.0: Configurable Sync Triggers
+ private val _triggerOnSave = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
+ )
+ val triggerOnSave: StateFlow = _triggerOnSave.asStateFlow()
+
+ private val _triggerOnResume = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
+ )
+ val triggerOnResume: StateFlow = _triggerOnResume.asStateFlow()
+
+ private val _triggerWifiConnect = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
+ )
+ val triggerWifiConnect: StateFlow = _triggerWifiConnect.asStateFlow()
+
+ private val _triggerPeriodic = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
+ )
+ val triggerPeriodic: StateFlow = _triggerPeriodic.asStateFlow()
+
+ private val _triggerBoot = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
+ )
+ val triggerBoot: StateFlow = _triggerBoot.asStateFlow()
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Markdown Settings State
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -126,32 +190,41 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Server Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /**
+ * v1.6.0: Set offline mode on/off
+ * When enabled, all network features are disabled
+ */
+ fun setOfflineMode(enabled: Boolean) {
+ _offlineMode.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
+
+ if (enabled) {
+ _serverStatus.value = ServerStatus.OfflineMode
+ } else {
+ // Re-check server status when disabling offline mode
+ checkServerStatus()
+ }
+ }
+
fun updateServerUrl(url: String) {
- _serverUrl.value = url
+ // π v1.6.0: Deprecated - use updateServerHost instead
+ // This function is kept for compatibility but now delegates to updateServerHost
+ val host = extractHostFromUrl(url)
+ updateServerHost(host)
+ }
+
+ /**
+ * π v1.6.0: Update only the host part of the server URL
+ * The protocol prefix is handled separately by updateProtocol()
+ */
+ fun updateServerHost(host: String) {
+ _serverHost.value = host
saveServerSettings()
}
fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps
- val currentUrl = _serverUrl.value
-
- // v1.5.0 Fix: Automatisch Prefix setzen, auch bei leerem Feld
- val newUrl = if (useHttps) {
- when {
- currentUrl.isEmpty() || currentUrl == "http://" -> "https://"
- currentUrl.startsWith("http://") -> currentUrl.replace("http://", "https://")
- !currentUrl.startsWith("https://") -> "https://$currentUrl"
- else -> currentUrl
- }
- } else {
- when {
- currentUrl.isEmpty() || currentUrl == "https://" -> "http://"
- currentUrl.startsWith("https://") -> currentUrl.replace("https://", "http://")
- !currentUrl.startsWith("http://") -> "http://$currentUrl"
- else -> currentUrl
- }
- }
- _serverUrl.value = newUrl
+ // π v1.6.0: Host stays the same, only prefix changes
saveServerSettings()
}
@@ -166,8 +239,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
private fun saveServerSettings() {
+ // π v1.6.0: Construct full URL from prefix + host
+ val prefix = if (_isHttps.value) "https://" else "http://"
+ val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
+
prefs.edit().apply {
- putString(Constants.KEY_SERVER_URL, _serverUrl.value)
+ putString(Constants.KEY_SERVER_URL, fullUrl)
putString(Constants.KEY_USERNAME, _username.value)
putString(Constants.KEY_PASSWORD, _password.value)
apply()
@@ -199,13 +276,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
fun checkServerStatus() {
- val serverUrl = _serverUrl.value
- // v1.5.0 Fix: URL mit nur Prefix gilt als "nicht konfiguriert"
- if (serverUrl.isEmpty() || serverUrl == "http://" || serverUrl == "https://") {
+ // π v1.6.0: Respect offline mode first
+ if (_offlineMode.value) {
+ _serverStatus.value = ServerStatus.OfflineMode
+ return
+ }
+
+ // π v1.6.0: Check if host is configured
+ val serverHost = _serverHost.value
+ if (serverHost.isEmpty()) {
_serverStatus.value = ServerStatus.NotConfigured
return
}
+ // Construct full URL
+ val prefix = if (_isHttps.value) "https://" else "http://"
+ val serverUrl = prefix + serverHost
+
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) {
@@ -287,6 +374,44 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
}
+ // π v1.6.0: Configurable Sync Triggers Setters
+
+ fun setTriggerOnSave(enabled: Boolean) {
+ _triggerOnSave.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
+ Logger.d(TAG, "Trigger onSave: $enabled")
+ }
+
+ fun setTriggerOnResume(enabled: Boolean) {
+ _triggerOnResume.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
+ Logger.d(TAG, "Trigger onResume: $enabled")
+ }
+
+ fun setTriggerWifiConnect(enabled: Boolean) {
+ _triggerWifiConnect.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
+ viewModelScope.launch {
+ _events.emit(SettingsEvent.RestartNetworkMonitor)
+ }
+ Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
+ }
+
+ fun setTriggerPeriodic(enabled: Boolean) {
+ _triggerPeriodic.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
+ viewModelScope.launch {
+ _events.emit(SettingsEvent.RestartNetworkMonitor)
+ }
+ Logger.d(TAG, "Trigger Periodic: $enabled")
+ }
+
+ fun setTriggerBoot(enabled: Boolean) {
+ _triggerBoot.value = enabled
+ prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
+ Logger.d(TAG, "Trigger Boot: $enabled")
+ }
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Markdown Settings Actions
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -371,6 +496,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
}
fun performManualMarkdownSync() {
+ // π v1.6.0: Block in offline mode
+ if (_offlineMode.value) {
+ Logger.d(TAG, "βοΈ Manual Markdown sync blocked: Offline mode enabled")
+ return
+ }
+
viewModelScope.launch {
try {
emitToast(getString(R.string.toast_markdown_syncing))
@@ -478,6 +609,20 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Helper
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ /**
+ * Check if server is configured AND not in offline mode
+ * v1.6.0: Returns false if offline mode is enabled
+ */
+ fun isServerConfigured(): Boolean {
+ // Offline mode takes priority
+ if (_offlineMode.value) return false
+
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ return !serverUrl.isNullOrEmpty() &&
+ serverUrl != "http://" &&
+ serverUrl != "https://"
+ }
+
private fun getString(resId: Int): String = getApplication().getString(resId)
private fun getString(resId: Int, vararg formatArgs: Any): String =
@@ -489,9 +634,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
/**
* Server status states
+ * v1.6.0: Added OfflineMode state
*/
sealed class ServerStatus {
data object Unknown : ServerStatus()
+ data object OfflineMode : ServerStatus() // π v1.6.0
data object NotConfigured : ServerStatus()
data object Checking : ServerStatus()
data object Reachable : ServerStatus()
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt
index 4322976..d9c5532 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt
@@ -95,24 +95,34 @@ fun SettingsDangerButton(
/**
* Info card with description text
+ * v1.6.0: Added isWarning parameter for offline mode warning
*/
@Composable
fun SettingsInfoCard(
text: String,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ isWarning: Boolean = false
) {
androidx.compose.material3.Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ containerColor = if (isWarning) {
+ MaterialTheme.colorScheme.errorContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ }
)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = if (isWarning) {
+ MaterialTheme.colorScheme.onErrorContainer
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
modifier = Modifier.padding(16.dp),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt
index 63cf998..9fc3143 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt
@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.settings.components
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -34,6 +35,7 @@ fun SettingsSwitch(
Row(
modifier = modifier
.fillMaxWidth()
+ .clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt
index a93bc19..e91ecc3 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt
@@ -49,6 +49,9 @@ fun BackupSettingsScreen(
) {
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
+ // π v1.6.0: Check if server restore is available
+ val isServerConfigured = viewModel.isServerConfigured()
+
// Restore dialog state
var showRestoreDialog by remember { mutableStateOf(false) }
var restoreSource by remember { mutableStateOf(RestoreSource.LocalFile) }
@@ -126,6 +129,7 @@ fun BackupSettingsScreen(
Spacer(modifier = Modifier.height(8.dp))
+ // π v1.6.0: Disabled when offline mode active
SettingsOutlinedButton(
text = stringResource(R.string.backup_restore_server),
onClick = {
@@ -133,9 +137,21 @@ fun BackupSettingsScreen(
showRestoreDialog = true
},
isLoading = isBackupInProgress,
+ enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp)
)
+ // π v1.6.0: Show hint when offline
+ if (!isServerConfigured) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.settings_sync_offline_mode),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+
Spacer(modifier = Modifier.height(16.dp))
}
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt
index d62f1b7..762b7c6 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt
@@ -42,6 +42,10 @@ fun MarkdownSettingsScreen(
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val exportProgress by viewModel.markdownExportProgress.collectAsState()
+ // π v1.6.0: Check offline mode
+ val offlineMode by viewModel.offlineMode.collectAsState()
+ val isServerConfigured = viewModel.isServerConfigured()
+
// v1.5.0 Fix: Progress Dialog for initial export
exportProgress?.let { progress ->
AlertDialog(
@@ -96,15 +100,22 @@ fun MarkdownSettingsScreen(
Spacer(modifier = Modifier.height(8.dp))
// Markdown Auto-Sync Toggle
+ // π v1.6.0: Disabled when offline mode active
SettingsSwitch(
title = stringResource(R.string.markdown_auto_sync_title),
- subtitle = stringResource(R.string.markdown_auto_sync_subtitle),
+ subtitle = if (!isServerConfigured) {
+ stringResource(R.string.settings_sync_offline_mode)
+ } else {
+ stringResource(R.string.markdown_auto_sync_subtitle)
+ },
checked = markdownAutoSync,
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
- icon = Icons.Default.Description
+ icon = Icons.Default.Description,
+ enabled = isServerConfigured
)
// Manual sync button (only visible when auto-sync is off)
+ // π v1.6.0: Also disabled in offline mode
if (!markdownAutoSync) {
SettingsDivider()
@@ -117,8 +128,20 @@ fun MarkdownSettingsScreen(
SettingsButton(
text = stringResource(R.string.markdown_manual_sync_button),
onClick = { viewModel.performManualMarkdownSync() },
+ enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp)
)
+
+ // π v1.6.0: Show hint when offline
+ if (!isServerConfigured) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.settings_sync_offline_mode),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
}
Spacer(modifier = Modifier.height(16.dp))
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt
index 9a533ac..d45a378 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt
@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.settings.screens
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -29,6 +30,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,6 +41,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource
@@ -52,13 +55,16 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Server configuration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
+ * v1.6.0: Offline Mode Toggle
*/
@Composable
fun ServerSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
- val serverUrl by viewModel.serverUrl.collectAsState()
+ val offlineMode by viewModel.offlineMode.collectAsState()
+ val serverHost by viewModel.serverHost.collectAsState() // π v1.6.0: Only host part
+ val serverUrl by viewModel.serverUrl.collectAsState() // Full URL for display
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val isHttps by viewModel.isHttps.collectAsState()
@@ -67,9 +73,11 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) }
- // Check server status on load
- LaunchedEffect(Unit) {
- viewModel.checkServerStatus()
+ // Check server status on load (only if not in offline mode)
+ LaunchedEffect(offlineMode) {
+ if (!offlineMode) {
+ viewModel.checkServerStatus()
+ }
}
SettingsScaffold(
@@ -83,99 +91,168 @@ fun ServerSettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
- // Verbindungstyp
- Text(
- text = stringResource(R.string.server_connection_type),
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.padding(bottom = 8.dp)
- )
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // π v1.6.0: Offline-Modus Toggle (TOP)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { viewModel.setOfflineMode(!offlineMode) },
+ colors = CardDefaults.cardColors(
+ containerColor = if (offlineMode) {
+ MaterialTheme.colorScheme.tertiaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ }
+ )
) {
- FilterChip(
- selected = !isHttps,
- onClick = { viewModel.updateProtocol(false) },
- label = { Text(stringResource(R.string.server_connection_http)) },
- modifier = Modifier.weight(1f)
- )
- FilterChip(
- selected = isHttps,
- onClick = { viewModel.updateProtocol(true) },
- label = { Text(stringResource(R.string.server_connection_https)) },
- modifier = Modifier.weight(1f)
- )
- }
-
- Text(
- text = if (!isHttps) {
- stringResource(R.string.server_connection_http_hint)
- } else {
- stringResource(R.string.server_connection_https_hint)
- },
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
- )
-
- // Server-Adresse
- OutlinedTextField(
- value = serverUrl,
- onValueChange = { viewModel.updateServerUrl(it) },
- label = { Text(stringResource(R.string.server_address)) },
- supportingText = { Text(stringResource(R.string.server_address_hint)) },
- leadingIcon = { Icon(Icons.Default.Language, null) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
- )
-
- Spacer(modifier = Modifier.height(12.dp))
-
- // Benutzername
- OutlinedTextField(
- value = username,
- onValueChange = { viewModel.updateUsername(it) },
- label = { Text(stringResource(R.string.username)) },
- leadingIcon = { Icon(Icons.Default.Person, null) },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true
- )
-
- Spacer(modifier = Modifier.height(12.dp))
-
- // Passwort
- OutlinedTextField(
- value = password,
- onValueChange = { viewModel.updatePassword(it) },
- label = { Text(stringResource(R.string.password)) },
- leadingIcon = { Icon(Icons.Default.Lock, null) },
- trailingIcon = {
- IconButton(onClick = { passwordVisible = !passwordVisible }) {
- Icon(
- imageVector = if (passwordVisible) {
- Icons.Default.VisibilityOff
- } else {
- Icons.Default.Visibility
- },
- contentDescription = if (passwordVisible) {
- stringResource(R.string.server_password_hide)
- } else {
- stringResource(R.string.server_password_show)
- }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.server_offline_mode_title),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = stringResource(R.string.server_offline_mode_subtitle),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- },
- visualTransformation = if (passwordVisible) {
- VisualTransformation.None
- } else {
- PasswordVisualTransformation()
- },
- modifier = Modifier.fillMaxWidth(),
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
- )
+ Switch(
+ checked = offlineMode,
+ onCheckedChange = { viewModel.setOfflineMode(it) }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // Server Configuration (grayed out when offline mode)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ val fieldsEnabled = !offlineMode
+ val fieldsAlpha = if (offlineMode) 0.5f else 1f
+
+ Column(modifier = Modifier.alpha(fieldsAlpha)) {
+ // Verbindungstyp
+ Text(
+ text = stringResource(R.string.server_connection_type),
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ FilterChip(
+ selected = !isHttps,
+ onClick = { viewModel.updateProtocol(false) },
+ label = { Text(stringResource(R.string.server_connection_http)) },
+ enabled = fieldsEnabled,
+ modifier = Modifier.weight(1f)
+ )
+ FilterChip(
+ selected = isHttps,
+ onClick = { viewModel.updateProtocol(true) },
+ label = { Text(stringResource(R.string.server_connection_https)) },
+ enabled = fieldsEnabled,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Text(
+ text = if (!isHttps) {
+ stringResource(R.string.server_connection_http_hint)
+ } else {
+ stringResource(R.string.server_connection_https_hint)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
+ )
+
+ // π v1.6.0: Server-Adresse with non-editable prefix
+ OutlinedTextField(
+ value = serverHost, // Only host part is editable
+ onValueChange = { viewModel.updateServerHost(it) },
+ label = { Text(stringResource(R.string.server_address)) },
+ supportingText = { Text(stringResource(R.string.server_address_hint)) },
+ prefix = {
+ // Protocol prefix is displayed but not editable
+ Text(
+ text = if (isHttps) "https://" else "http://",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (fieldsEnabled) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ }
+ )
+ },
+ leadingIcon = { Icon(Icons.Default.Language, null) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = fieldsEnabled,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Benutzername
+ OutlinedTextField(
+ value = username,
+ onValueChange = { viewModel.updateUsername(it) },
+ label = { Text(stringResource(R.string.username)) },
+ leadingIcon = { Icon(Icons.Default.Person, null) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = fieldsEnabled
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Passwort
+ OutlinedTextField(
+ value = password,
+ onValueChange = { viewModel.updatePassword(it) },
+ label = { Text(stringResource(R.string.password)) },
+ leadingIcon = { Icon(Icons.Default.Lock, null) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ imageVector = if (passwordVisible) {
+ Icons.Default.VisibilityOff
+ } else {
+ Icons.Default.Visibility
+ },
+ contentDescription = if (passwordVisible) {
+ stringResource(R.string.server_password_hide)
+ } else {
+ stringResource(R.string.server_password_show)
+ }
+ )
+ }
+ },
+ visualTransformation = if (passwordVisible) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ enabled = fieldsEnabled,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
+ )
+ }
Spacer(modifier = Modifier.height(16.dp))
@@ -196,16 +273,18 @@ fun ServerSettingsScreen(
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
Text(
text = when (serverStatus) {
+ is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.server_status_offline_mode)
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
- is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured)
+ is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
else -> stringResource(R.string.server_status_unknown)
},
color = when (serverStatus) {
+ is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
- is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
+ is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
@@ -214,13 +293,16 @@ fun ServerSettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
- // Action Buttons
+ // Action Buttons (disabled in offline mode)
Row(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(fieldsAlpha),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
+ enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.test_connection))
@@ -228,7 +310,7 @@ fun ServerSettingsScreen(
Button(
onClick = { viewModel.syncNow() },
- enabled = !isSyncing,
+ enabled = fieldsEnabled && !isSyncing,
modifier = Modifier.weight(1f)
) {
if (isSyncing) {
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt
index 29bc686..6814659 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -45,6 +46,14 @@ fun SettingsMainScreen(
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
+ // π v1.6.0: Collect offline mode and trigger states
+ val offlineMode by viewModel.offlineMode.collectAsState()
+ val triggerOnSave by viewModel.triggerOnSave.collectAsState()
+ val triggerOnResume by viewModel.triggerOnResume.collectAsState()
+ val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
+ val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
+ val triggerBoot by viewModel.triggerBoot.collectAsState()
+
// Check server status on first load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
@@ -82,26 +91,28 @@ fun SettingsMainScreen(
// Server-Einstellungen
item {
- // v1.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert"
- val isConfigured = serverUrl.isNotEmpty() &&
- serverUrl != "http://" &&
- serverUrl != "https://"
+ // π v1.6.0: Check if server is configured (host is not empty)
+ val isConfigured = serverUrl.isNotEmpty()
SettingsCard(
icon = Icons.Default.Cloud,
title = stringResource(R.string.settings_server),
- subtitle = if (isConfigured) serverUrl else null,
- statusText = when (serverStatus) {
- is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable)
- is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable)
- is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking)
- is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_not_configured)
+ subtitle = if (!offlineMode && isConfigured) serverUrl else null,
+ statusText = when {
+ offlineMode -> stringResource(R.string.settings_server_status_offline_mode)
+ serverStatus is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.settings_server_status_offline_mode)
+ serverStatus is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable)
+ serverStatus is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable)
+ serverStatus is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking)
+ serverStatus is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_offline_mode)
else -> null
},
- statusColor = when (serverStatus) {
- is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
- is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
- is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
+ statusColor = when {
+ offlineMode -> MaterialTheme.colorScheme.tertiary
+ serverStatus is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
+ serverStatus is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
+ serverStatus is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
+ serverStatus is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
else -> Color.Gray
},
onClick = { onNavigate(SettingsRoute.Server) }
@@ -110,33 +121,52 @@ fun SettingsMainScreen(
// Sync-Einstellungen
item {
- val intervalText = when (syncInterval) {
- 15L -> stringResource(R.string.settings_interval_15min)
- 60L -> stringResource(R.string.settings_interval_60min)
- else -> stringResource(R.string.settings_interval_30min)
- }
+ // π v1.6.0: Build dynamic subtitle based on active triggers
+ val isServerConfigured = viewModel.isServerConfigured()
+ val activeTriggersCount = listOf(
+ triggerOnSave,
+ triggerOnResume,
+ triggerWifiConnect,
+ triggerPeriodic,
+ triggerBoot
+ ).count { it }
+
+ // π v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
+ val syncSubtitle = if (isServerConfigured) {
+ if (activeTriggersCount == 0) {
+ stringResource(R.string.settings_sync_manual_only)
+ } else {
+ stringResource(R.string.settings_sync_triggers_active, activeTriggersCount)
+ }
+ } else null
+
SettingsCard(
icon = Icons.Default.Sync,
title = stringResource(R.string.settings_sync),
- subtitle = if (autoSyncEnabled) {
- stringResource(R.string.settings_sync_auto_on, intervalText)
- } else {
- stringResource(R.string.settings_sync_auto_off)
- },
+ subtitle = syncSubtitle,
+ statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
+ statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Sync) }
)
}
// Markdown-Integration
item {
+ // π v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
+ val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
+
SettingsCard(
icon = Icons.Default.Description,
title = stringResource(R.string.settings_markdown),
- subtitle = if (markdownAutoSync) {
- stringResource(R.string.settings_markdown_auto_on)
- } else {
- stringResource(R.string.settings_markdown_auto_off)
- },
+ subtitle = if (isServerConfiguredForMarkdown) {
+ if (markdownAutoSync) {
+ stringResource(R.string.settings_markdown_auto_on)
+ } else {
+ stringResource(R.string.settings_markdown_auto_off)
+ }
+ } else null,
+ statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
+ statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Markdown) }
)
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt
index b2196a8..4d26cdb 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt
@@ -8,7 +8,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material.icons.filled.PhonelinkRing
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.SettingsInputAntenna
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -26,17 +33,27 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
- * Sync settings screen (Auto-Sync toggle and interval selection)
+ * Sync settings screen - Configurable Sync Triggers
* v1.5.0: Jetpack Compose Settings Redesign
+ * v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
*/
@Composable
fun SyncSettingsScreen(
viewModel: SettingsViewModel,
- onBack: () -> Unit
+ onBack: () -> Unit,
+ onNavigateToServerSettings: () -> Unit
) {
- val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
+ // Collect all trigger states
+ val triggerOnSave by viewModel.triggerOnSave.collectAsState()
+ val triggerOnResume by viewModel.triggerOnResume.collectAsState()
+ val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
+ val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
+ val triggerBoot by viewModel.triggerBoot.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
+ // Check if server is configured
+ val isServerConfigured = viewModel.isServerConfigured()
+
SettingsScaffold(
title = stringResource(R.string.sync_settings_title),
onBack = onBack
@@ -49,55 +66,137 @@ fun SyncSettingsScreen(
) {
Spacer(modifier = Modifier.height(8.dp))
- // Auto-Sync Info
- SettingsInfoCard(
- text = stringResource(R.string.sync_auto_sync_info)
+ // π v1.6.0: Offline Mode Warning if server not configured
+ if (!isServerConfigured) {
+ SettingsInfoCard(
+ text = stringResource(R.string.sync_offline_mode_message),
+ isWarning = true
+ )
+
+ Button(
+ onClick = onNavigateToServerSettings,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Text(stringResource(R.string.sync_offline_mode_button))
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // SOFORT-SYNC Section
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
+
+ // onSave Trigger
+ SettingsSwitch(
+ title = stringResource(R.string.sync_trigger_on_save_title),
+ subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
+ checked = triggerOnSave,
+ onCheckedChange = { viewModel.setTriggerOnSave(it) },
+ icon = Icons.Default.Save,
+ enabled = isServerConfigured
)
- Spacer(modifier = Modifier.height(8.dp))
-
- // Auto-Sync Toggle
+ // onResume Trigger
SettingsSwitch(
- title = stringResource(R.string.sync_auto_sync_enabled),
- checked = autoSyncEnabled,
- onCheckedChange = { viewModel.setAutoSync(it) },
- icon = Icons.Default.Sync
+ title = stringResource(R.string.sync_trigger_on_resume_title),
+ subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
+ checked = triggerOnResume,
+ onCheckedChange = { viewModel.setTriggerOnResume(it) },
+ icon = Icons.Default.PhonelinkRing,
+ enabled = isServerConfigured
)
SettingsDivider()
- // Sync Interval Section
- SettingsSectionHeader(text = stringResource(R.string.sync_interval_section))
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // HINTERGRUND-SYNC Section
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
+
+ // WiFi-Connect Trigger
+ SettingsSwitch(
+ title = stringResource(R.string.sync_trigger_wifi_connect_title),
+ subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
+ checked = triggerWifiConnect,
+ onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
+ icon = Icons.Default.Wifi,
+ enabled = isServerConfigured
+ )
+
+ // Periodic Trigger
+ SettingsSwitch(
+ title = stringResource(R.string.sync_trigger_periodic_title),
+ subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
+ checked = triggerPeriodic,
+ onCheckedChange = { viewModel.setTriggerPeriodic(it) },
+ icon = Icons.Default.Schedule,
+ enabled = isServerConfigured
+ )
+
+ // Periodic Interval Selection (only visible if periodic trigger is enabled)
+ if (triggerPeriodic && isServerConfigured) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val intervalOptions = listOf(
+ RadioOption(
+ value = 15L,
+ title = stringResource(R.string.sync_interval_15min_title),
+ subtitle = null
+ ),
+ RadioOption(
+ value = 30L,
+ title = stringResource(R.string.sync_interval_30min_title),
+ subtitle = null
+ ),
+ RadioOption(
+ value = 60L,
+ title = stringResource(R.string.sync_interval_60min_title),
+ subtitle = null
+ )
+ )
+
+ SettingsRadioGroup(
+ options = intervalOptions,
+ selectedValue = syncInterval,
+ onValueSelected = { viewModel.setSyncInterval(it) }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ SettingsDivider()
+
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // ADVANCED Section (Boot Sync)
+ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
+
+ // Boot Trigger
+ SettingsSwitch(
+ title = stringResource(R.string.sync_trigger_boot_title),
+ subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
+ checked = triggerBoot,
+ onCheckedChange = { viewModel.setTriggerBoot(it) },
+ icon = Icons.Default.SettingsInputAntenna,
+ enabled = isServerConfigured
+ )
+
+ SettingsDivider()
+
+ // Manual Sync Info
+ val manualHintText = if (isServerConfigured) {
+ stringResource(R.string.sync_manual_hint)
+ } else {
+ stringResource(R.string.sync_manual_hint_disabled)
+ }
SettingsInfoCard(
- text = stringResource(R.string.sync_interval_info)
- )
-
- Spacer(modifier = Modifier.height(8.dp))
-
- // Interval Radio Group
- val intervalOptions = listOf(
- RadioOption(
- value = 15L,
- title = stringResource(R.string.sync_interval_15min_title),
- subtitle = stringResource(R.string.sync_interval_15min_subtitle)
- ),
- RadioOption(
- value = 30L,
- title = stringResource(R.string.sync_interval_30min_title),
- subtitle = stringResource(R.string.sync_interval_30min_subtitle)
- ),
- RadioOption(
- value = 60L,
- title = stringResource(R.string.sync_interval_60min_title),
- subtitle = stringResource(R.string.sync_interval_60min_subtitle)
- )
- )
-
- SettingsRadioGroup(
- options = intervalOptions,
- selectedValue = syncInterval,
- onValueSelected = { viewModel.setSyncInterval(it) }
+ text = manualHintText
)
Spacer(modifier = Modifier.height(16.dp))
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
index e10daac..91e95c4 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
@@ -29,6 +29,27 @@ object Constants {
// π₯ v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
+ // π₯ v1.6.0: Offline Mode Toggle
+ const val KEY_OFFLINE_MODE = "offline_mode_enabled"
+
+ // π₯ v1.6.0: Configurable Sync Triggers
+ const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
+ const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
+ const val KEY_SYNC_TRIGGER_WIFI_CONNECT = "sync_trigger_wifi_connect"
+ const val KEY_SYNC_TRIGGER_PERIODIC = "sync_trigger_periodic"
+ const val KEY_SYNC_TRIGGER_BOOT = "sync_trigger_boot"
+
+ // Sync Trigger Defaults (active after server configuration)
+ const val DEFAULT_TRIGGER_ON_SAVE = true
+ const val DEFAULT_TRIGGER_ON_RESUME = true
+ const val DEFAULT_TRIGGER_WIFI_CONNECT = true
+ const val DEFAULT_TRIGGER_PERIODIC = false
+ const val DEFAULT_TRIGGER_BOOT = false
+
+ // Throttling for onSave sync (5 seconds)
+ const val MIN_ON_SAVE_SYNC_INTERVAL_MS = 5_000L
+ const val PREF_LAST_ON_SAVE_SYNC_TIME = "last_on_save_sync_time"
+
// WorkManager
const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 036aa34..309a676 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -65,6 +65,7 @@
Wie mΓΆchtest du diese Notiz lΓΆschen?
Wie mΓΆchtest du diese %d Notizen lΓΆschen?
Γberall lΓΆschen (auch Server)
+ Nicht verfΓΌgbar im Offline-Modus
Nur lokal lΓΆschen
LΓΆschen
Abbrechen
@@ -135,9 +136,13 @@
β Nicht erreichbar
π PrΓΌfeβ¦
β οΈ Nicht konfiguriert
+ π΄ Offline-Modus
Sync-Einstellungen
Auto-Sync: An β’ %s
Auto-Sync: Aus
+ π΄ Offline-Modus
+ Nur manueller Sync
+ %d Trigger aktiv
15 Min
30 Min
60 Min
@@ -173,7 +178,10 @@
β Nicht erreichbar
π PrΓΌfeβ¦
β οΈ Nicht konfiguriert
+ π΄ Offline-Modus aktiv
β Unbekannt
+ π΄ Offline-Modus
+ Alle Netzwerkfunktionen deaktivieren
Verbindung testen
Jetzt synchronisieren
@@ -196,6 +204,33 @@
βΉοΈ Auto-Sync:\n\nβ’ PrΓΌft alle 30 Min ob Server erreichbar\nβ’ Funktioniert bei jeder WiFi-Verbindung\nβ’ LΓ€uft auch im Hintergrund\nβ’ Minimaler Akkuverbrauch (~0.4%%/Tag)
+
+ π² Sofort-Sync
+ π‘ Hintergrund-Sync
+ βοΈ Erweitert
+
+ Nach dem Speichern
+ Sync sofort nach jeder Γnderung
+
+ Beim App-Start
+ Sync wenn die App geΓΆffnet wird
+
+ Bei WiFi-Verbindung
+ Sync wenn WiFi verbunden wird
+
+ Automatisch alle X Minuten
+ RegelmΓ€Γiger Hintergrund-Sync
+
+ Nach GerΓ€t-Neustart
+ Startet Hintergrund-Sync nach Reboot
+
+ Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfΓΌgbar.
+ Sync ist im Offline-Modus nicht verfΓΌgbar.
+
+ Offline-Modus
+ Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.
+ Server einrichten
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index cd32c30..ec22b9a 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -66,6 +66,7 @@
How do you want to delete this note?
How do you want to delete these %d notes?
Delete everywhere (also server)
+ Not available in offline mode
Delete local only
Delete
Cancel
@@ -136,9 +137,13 @@
β Not reachable
π Checkingβ¦
β οΈ Not configured
+ π΄ Offline Mode
Sync Settings
Auto-Sync: On β’ %s
Auto-Sync: Off
+ π΄ Offline Mode
+ Manual sync only
+ %d triggers active
15 min
30 min
60 min
@@ -174,7 +179,10 @@
β Not reachable
π Checkingβ¦
β οΈ Not configured
+ π΄ Offline mode active
β Unknown
+ π΄ Offline Mode
+ Disable all network features
Test Connection
Sync now
@@ -197,6 +205,33 @@
βΉοΈ Auto-Sync:\n\nβ’ Checks every 30 min if server is reachable\nβ’ Works on any WiFi connection\nβ’ Runs in background\nβ’ Minimal battery usage (~0.4%%/day)
+
+ π² Instant Sync
+ π‘ Background Sync
+ βοΈ Advanced
+
+ After Saving
+ Sync immediately after each change
+
+ On App Start
+ Sync when the app is opened
+
+ On WiFi Connection
+ Sync when WiFi is connected
+
+ Automatically every X minutes
+ Regular background sync
+
+ After Device Restart
+ Starts background sync after reboot
+
+ Manual sync (toolbar/pull-to-refresh) is also available.
+ Sync is not available in offline mode.
+
+ Offline Mode
+ You are using the app in offline mode. Set up a server to synchronize notes.
+ Set Up Server
+
diff --git a/docs/DOCS.de.md b/docs/DOCS.de.md
index d371b78..5197e07 100644
--- a/docs/DOCS.de.md
+++ b/docs/DOCS.de.md
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
## π Akku-Optimierung
-### Verbrauchsanalyse
+### v1.6.0: Konfigurierbare Sync-Trigger
+
+Seit v1.6.0 kann jeder Sync-Trigger einzeln aktiviert/deaktiviert werden. Das gibt Nutzern feine Kontrolle ΓΌber den Akkuverbrauch.
+
+#### Sync-Trigger Γbersicht
+
+| Trigger | Standard | Akku-Impact | Beschreibung |
+|---------|----------|-------------|--------------|
+| **Manueller Sync** | Immer an | 0 (nutzer-getriggert) | Toolbar-Button / Pull-to-Refresh |
+| **onSave Sync** | β
AN | ~0.5 mAh/Speichern | Sync sofort nach Speichern einer Notiz |
+| **onResume Sync** | β
AN | ~0.3 mAh/Γffnen | Sync beim App-Γffnen (60s Throttle) |
+| **WiFi-Connect** | β
AN | ~0.5 mAh/Verbindung | Sync bei WiFi-Verbindung |
+| **Periodic Sync** | β AUS | 0.2-0.8%/Tag | Hintergrund-Sync alle 15/30/60 Min |
+| **Boot Sync** | β AUS | ~0.1 mAh/Boot | Start Hintergrund-Sync nach Neustart |
+
+#### Akku-Verbrauchsberechnung
+
+**Typisches Nutzungsszenario (Standardeinstellungen):**
+- onSave: ~5 Speichern/Tag Γ 0.5 mAh = **~2.5 mAh**
+- onResume: ~10 Γffnen/Tag Γ 0.3 mAh = **~3 mAh**
+- WiFi-Connect: ~2 Verbindungen/Tag Γ 0.5 mAh = **~1 mAh**
+- **Gesamt: ~6.5 mAh/Tag (~0.2% bei 3000mAh Akku)**
+
+**Mit aktiviertem Periodic Sync (15/30/60 Min):**
+
+| Intervall | Syncs/Tag | Akku/Tag | Gesamt (mit Standards) |
+|-----------|-----------|----------|------------------------|
+| **15 Min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
+| **30 Min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
+| **60 Min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
+
+#### Komponenten-AufschlΓΌsselung
| Komponente | Frequenz | Verbrauch | Details |
|------------|----------|-----------|---------|
-| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
-| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
-| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Γnderungen |
-| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
+| WorkManager Wakeup | Pro Sync | ~0.15 mAh | System wacht auf |
+| Network Check | Pro Sync | ~0.03 mAh | Gateway IP Check |
+| WebDAV Sync | Nur bei Γnderungen | ~0.25 mAh | HTTP PUT/GET |
+| **Pro-Sync Gesamt** | - | **~0.25 mAh** | Optimiert |
### Optimierungen
-1. **IP Caching**
+1. **Pre-Checks vor Sync**
+ ```kotlin
+ // Reihenfolge wichtig! GΓΌnstigste Checks zuerst
+ if (!hasUnsyncedChanges()) return // Lokaler Check (gΓΌnstig)
+ if (!isServerReachable()) return // Netzwerk Check (teuer)
+ performSync() // Nur wenn beide bestehen
+ ```
+
+2. **Throttling**
+ - onResume: 60 Sekunden Mindestabstand
+ - onSave: 5 Sekunden Mindestabstand
+ - Periodic: 15/30/60 Minuten Intervalle
+
+3. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup nur 1x beim Start, nicht bei jedem Check
```
-2. **Throttling**
- ```kotlin
- private var lastSyncTime = 0L
- private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
- ```
-
-3. **Conditional Logging**
+4. **Conditional Logging**
```kotlin
object Logger {
fun d(tag: String, msg: String) {
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
}
```
-4. **Network Constraints**
+5. **Network Constraints**
- Nur WiFi (nicht mobile Daten)
- Nur wenn Server erreichbar
- Keine permanenten Listeners
diff --git a/docs/DOCS.md b/docs/DOCS.md
index cc548ef..bd2a2bc 100644
--- a/docs/DOCS.md
+++ b/docs/DOCS.md
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
## π Battery Optimization
-### Usage Analysis
+### v1.6.0: Configurable Sync Triggers
+
+Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.
+
+#### Sync Trigger Overview
+
+| Trigger | Default | Battery Impact | Description |
+|---------|---------|----------------|-------------|
+| **Manual Sync** | Always on | 0 (user-triggered) | Toolbar button / Pull-to-refresh |
+| **onSave Sync** | β
ON | ~0.5 mAh/save | Sync immediately after saving a note |
+| **onResume Sync** | β
ON | ~0.3 mAh/resume | Sync when app is opened (60s throttle) |
+| **WiFi-Connect** | β
ON | ~0.5 mAh/connect | Sync when WiFi is connected |
+| **Periodic Sync** | β OFF | 0.2-0.8%/day | Background sync every 15/30/60 min |
+| **Boot Sync** | β OFF | ~0.1 mAh/boot | Start background sync after reboot |
+
+#### Battery Usage Calculation
+
+**Typical usage scenario (defaults):**
+- onSave: ~5 saves/day Γ 0.5 mAh = **~2.5 mAh**
+- onResume: ~10 opens/day Γ 0.3 mAh = **~3 mAh**
+- WiFi-Connect: ~2 connects/day Γ 0.5 mAh = **~1 mAh**
+- **Total: ~6.5 mAh/day (~0.2% on 3000mAh battery)**
+
+**With Periodic Sync enabled (15/30/60 min):**
+
+| Interval | Syncs/day | Battery/day | Total (with defaults) |
+|----------|-----------|-------------|----------------------|
+| **15 min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
+| **30 min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
+| **60 min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
+
+#### Component Breakdown
| Component | Frequency | Usage | Details |
-|------------|----------|-----------|---------|
-| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
-| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
-| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
-| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
+|-----------|-----------|-------|---------|
+| WorkManager Wakeup | Per sync | ~0.15 mAh | System wakes up |
+| Network Check | Per sync | ~0.03 mAh | Gateway IP check |
+| WebDAV Sync | Only if changes | ~0.25 mAh | HTTP PUT/GET |
+| **Per-Sync Total** | - | **~0.25 mAh** | Optimized |
### Optimizations
-1. **IP Caching**
+1. **Pre-Checks before Sync**
+ ```kotlin
+ // Order matters! Cheapest checks first
+ if (!hasUnsyncedChanges()) return // Local check (cheap)
+ if (!isServerReachable()) return // Network check (expensive)
+ performSync() // Only if both pass
+ ```
+
+2. **Throttling**
+ - onResume: 60 second minimum interval
+ - onSave: 5 second minimum interval
+ - Periodic: 15/30/60 minute intervals
+
+3. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup only once at start, not every check
```
-2. **Throttling**
- ```kotlin
- private var lastSyncTime = 0L
- private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
- ```
-
-3. **Conditional Logging**
+4. **Conditional Logging**
```kotlin
object Logger {
fun d(tag: String, msg: String) {
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
}
```
-4. **Network Constraints**
+5. **Network Constraints**
- WiFi only (not mobile data)
- Only when server is reachable
- No permanent listeners
diff --git a/docs/FEATURES.de.md b/docs/FEATURES.de.md
index 3610d2a..77a17d7 100644
--- a/docs/FEATURES.de.md
+++ b/docs/FEATURES.de.md
@@ -169,16 +169,19 @@
## π Performance & Optimierung
-### Akku-Effizienz
-- β
**Optimierte Sync-Intervalle** - 15/30/60 Min
+### Akku-Effizienz (v1.6.0)
+- β
**Konfigurierbare Sync-Trigger** - Jeden Trigger einzeln aktivieren/deaktivieren
+- β
**Smarte Defaults** - Nur ereignisbasierte Trigger standardmΓ€Γig aktiv
+- β
**Optimierte Periodische Intervalle** - 15/30/60 Min (Standard: AUS)
- β
**WiFi-Only** - Kein Mobile Data Sync
- β
**Smart Server-Check** - Sync nur wenn Server erreichbar
- β
**WorkManager** - System-optimierte AusfΓΌhrung
- β
**Doze Mode kompatibel** - Sync lΓ€uft auch im Standby
- β
**Gemessener Verbrauch:**
- - 15 Min: ~0.8% / Tag (~23 mAh)
- - 30 Min: ~0.4% / Tag (~12 mAh) β _Empfohlen_
- - 60 Min: ~0.2% / Tag (~6 mAh)
+ - Standard (nur ereignisbasiert): ~0.2%/Tag (~6.5 mAh) β _Optimal_
+ - Mit Periodic 15 Min: ~1.0%/Tag (~30 mAh)
+ - Mit Periodic 30 Min: ~0.6%/Tag (~19 mAh)
+ - Mit Periodic 60 Min: ~0.4%/Tag (~13 mAh)
### App-Performance
- β
**Offline-First** - Funktioniert ohne Internet
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 09d39f8..7ed87ae 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -169,16 +169,19 @@
## π Performance & Optimization
-### Battery Efficiency
-- β
**Optimized sync intervals** - 15/30/60 min
+### Battery Efficiency (v1.6.0)
+- β
**Configurable sync triggers** - Enable/disable each trigger individually
+- β
**Smart defaults** - Only event-driven triggers active by default
+- β
**Optimized periodic intervals** - 15/30/60 min (default: OFF)
- β
**WiFi-only** - No mobile data sync
- β
**Smart server check** - Sync only when server is reachable
- β
**WorkManager** - System-optimized execution
- β
**Doze mode compatible** - Sync runs even in standby
- β
**Measured consumption:**
- - 15 min: ~0.8% / day (~23 mAh)
- - 30 min: ~0.4% / day (~12 mAh) β _Recommended_
- - 60 min: ~0.2% / day (~6 mAh)
+ - Default (event-driven only): ~0.2%/day (~6.5 mAh) β _Optimal_
+ - With periodic 15 min: ~1.0%/day (~30 mAh)
+ - With periodic 30 min: ~0.6%/day (~19 mAh)
+ - With periodic 60 min: ~0.4%/day (~13 mAh)
### App Performance
- β
**Offline-first** - Works without internet
diff --git a/docs/UPCOMING.de.md b/docs/UPCOMING.de.md
index 50f3554..361cb0d 100644
--- a/docs/UPCOMING.de.md
+++ b/docs/UPCOMING.de.md
@@ -33,7 +33,16 @@
## v1.6.0 - Technische Modernisierung
-> **Status:** In Planung π
+> **Status:** In Entwicklung π§
+
+### βοΈ Konfigurierbare Sync-Trigger
+
+- β
**Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln aktivieren/deaktivieren
+- β
**Ereignisbasierte Defaults** - onSave, onResume, WiFi-Connect standardmΓ€Γig aktiv
+- β
**Periodischer Sync optional** - 15/30/60 Min Intervalle (Standard: AUS)
+- β
**Boot Sync optional** - Periodischen Sync nach GerΓ€teneustart starten (Standard: AUS)
+- β
**Offline-Modus UI** - Ausgegraute Toggles wenn kein Server konfiguriert
+- β
**Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit Periodic
### π§ Server-Ordner PrΓΌfung
diff --git a/docs/UPCOMING.md b/docs/UPCOMING.md
index 3ac984e..eb06e2f 100644
--- a/docs/UPCOMING.md
+++ b/docs/UPCOMING.md
@@ -33,7 +33,16 @@
## v1.6.0 - Technical Modernization
-> **Status:** Planned π
+> **Status:** In Development π§
+
+### βοΈ Configurable Sync Triggers
+
+- β
**Individual trigger control** - Enable/disable each sync trigger separately
+- β
**Event-driven defaults** - onSave, onResume, WiFi-Connect active by default
+- β
**Periodic sync optional** - 15/30/60 min intervals (default: OFF)
+- β
**Boot sync optional** - Start periodic sync after device restart (default: OFF)
+- β
**Offline mode UI** - Grayed-out toggles when no server configured
+- β
**Battery optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic
### π§ Server Folder Check
diff --git a/docs/v1.6.0_OFFLINE_DELETE_RESTRICTION.md b/docs/v1.6.0_OFFLINE_DELETE_RESTRICTION.md
new file mode 100644
index 0000000..174d760
--- /dev/null
+++ b/docs/v1.6.0_OFFLINE_DELETE_RESTRICTION.md
@@ -0,0 +1,315 @@
+# v1.6.0 Feature: Server-LΓΆsch-EinschrΓ€nkung im Offline-Modus
+
+## π Γbersicht
+
+**Problem:** Im Offline-Modus kann der Benutzer immer noch "Γberall lΓΆschen (auch Server)" auswΓ€hlen, was zu Netzwerkverkehr fΓΌhrt (auch wenn die Anfrage fehlschlΓ€gt).
+
+**Ziel:** Die "Γberall lΓΆschen"-Option im Offline-Modus subtil aber intuitiv deaktivieren, um echte Offline-Nutzung zu gewΓ€hrleisten.
+
+---
+
+## π Analyse der betroffenen Komponenten
+
+### 1. DeleteConfirmationDialog
+**Datei:** [DeleteConfirmationDialog.kt](../android/app/src/main/java/dev/dettmer/simplenotes/ui/main/components/DeleteConfirmationDialog.kt)
+
+**Aktueller Zustand:**
+- Zeigt zwei Optionen: "Γberall lΓΆschen" und "Nur lokal lΓΆschen"
+- Keine BerΓΌcksichtigung des Offline-Modus
+- Verwendet in: `MainScreen.kt` (Batch-Delete) und `NoteEditorScreen.kt` (Einzelne Notiz)
+
+**Γnderungen:**
+- Neuer Parameter: `isOfflineMode: Boolean = false`
+- "Γberall lΓΆschen" Button: `enabled = !isOfflineMode`
+- Subtile visuelle Kennzeichnung wenn deaktiviert
+
+### 2. Verwendungsstellen
+
+| Datei | Verwendung | ViewModel-Zugriff |
+|-------|------------|-------------------|
+| `MainScreen.kt` | Batch-LΓΆschung | `MainViewModel.isOfflineMode` β
bereits vorhanden |
+| `NoteEditorScreen.kt` | Einzelne Notiz | `NoteEditorViewModel` β benΓΆtigt Erweiterung |
+
+### 3. NoteEditorViewModel
+**Datei:** [NoteEditorViewModel.kt](../android/app/src/main/java/dev/dettmer/simplenotes/ui/editor/NoteEditorViewModel.kt)
+
+**Aktueller Zustand:**
+- PrΓΌft Offline-Status nur fΓΌr `triggerOnSaveSync()` inline via `prefs.getString(KEY_SERVER_URL, null)`
+- Kein reaktiver State fΓΌr Offline-Modus
+
+**Γnderungen:**
+- Neuer StateFlow: `isOfflineMode: StateFlow`
+
+---
+
+## π Technische Design-Entscheidungen
+
+### UI/UX Design fΓΌr deaktivierte Option
+
+#### Option A: Grayed-out mit Tooltip-Hinweis β
**EMPFOHLEN**
+```
+βββββββββββββββββββββββββββββββββββββββββββ
+β Notiz lΓΆschen? β
+β β
+β Wie mΓΆchtest du diese Notiz lΓΆschen? β
+β β
+β ββββββββββββββββββββββββββββββββββββββββ
+β β Γberall lΓΆschen (auch Server) ββ β Grau, nicht anklickbar
+β β π΄ Nicht verfΓΌgbar im Offline-Modus ββ β Subtiler Hinweis
+β ββββββββββββββββββββββββββββββββββββββββ
+β β
+β ββββββββββββββββββββββββββββββββββββββββ
+β β β Nur lokal lΓΆschen ββ β Normal, anklickbar
+β ββββββββββββββββββββββββββββββββββββββββ
+β β
+β ββββββββββββββββββββββββββββββββββββββββ
+β β Abbrechen ββ
+β ββββββββββββββββββββββββββββββββββββββββ
+βββββββββββββββββββββββββββββββββββββββββββ
+```
+
+**Vorteile:**
+- Konsistent mit bestehendem v1.6.0 Pattern (BackupSettingsScreen, MarkdownSettingsScreen)
+- Benutzer sieht sofort warum Option nicht verfΓΌgbar
+- Keine Verwirrung - klare Ursache angegeben
+
+#### Option B: Button komplett ausblenden β
+**Nachteile:**
+- Verwirrend fΓΌr Benutzer die den Button sonst sehen
+- Inkonsistent mit v1.6.0 Design-Pattern
+
+#### Option C: Button mit Toast-Feedback β
+**Nachteile:**
+- Schlechte UX - warum klickbar wenn nicht mΓΆglich?
+- Frustierend fΓΌr Benutzer
+
+---
+
+## π Implementierungs-Plan
+
+### Phase 1: DeleteConfirmationDialog erweitern
+
+**Schritt 1.1:** Neuer Parameter und String-Ressourcen
+
+```kotlin
+// DeleteConfirmationDialog.kt
+@Composable
+fun DeleteConfirmationDialog(
+ noteCount: Int = 1,
+ isOfflineMode: Boolean = false, // π v1.6.0: NEU
+ onDismiss: () -> Unit,
+ onDeleteLocal: () -> Unit,
+ onDeleteEverywhere: () -> Unit
+)
+```
+
+**Neue Strings:**
+```xml
+
+Not available in offline mode
+
+
+Nicht verfΓΌgbar im Offline-Modus
+```
+
+**Schritt 1.2:** UI-Anpassung fΓΌr deaktivierten Button
+
+```kotlin
+// Delete everywhere (server + local) - primary action
+// π v1.6.0: Disabled in offline mode
+Column {
+ TextButton(
+ onClick = onDeleteEverywhere,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = !isOfflineMode, // π NEU
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error,
+ disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
+ )
+ ) {
+ Text(stringResource(R.string.delete_everywhere))
+ }
+
+ // π v1.6.0: Show hint when offline
+ if (isOfflineMode) {
+ Text(
+ text = stringResource(R.string.delete_everywhere_offline_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
+ )
+ }
+}
+```
+
+### Phase 2: MainScreen anpassen (Batch-LΓΆschung)
+
+**Datei:** `MainScreen.kt`
+
+**Aktuelle Verwendung (Zeile ~218):**
+```kotlin
+DeleteConfirmationDialog(
+ noteCount = selectedNotes.size,
+ onDismiss = { showBatchDeleteDialog = false },
+ onDeleteLocal = { ... },
+ onDeleteEverywhere = { ... }
+)
+```
+
+**Γnderung:**
+```kotlin
+DeleteConfirmationDialog(
+ noteCount = selectedNotes.size,
+ isOfflineMode = isOfflineMode, // π v1.6.0: NEU - bereits als State vorhanden
+ onDismiss = { showBatchDeleteDialog = false },
+ onDeleteLocal = { ... },
+ onDeleteEverywhere = { ... }
+)
+```
+
+### Phase 3: NoteEditorScreen + NoteEditorViewModel anpassen
+
+**Schritt 3.1:** NoteEditorViewModel erweitern
+
+```kotlin
+// NoteEditorViewModel.kt
+
+// π v1.6.0: Offline Mode State
+private val _isOfflineMode = MutableStateFlow(
+ prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
+)
+val isOfflineMode: StateFlow = _isOfflineMode.asStateFlow()
+```
+
+**Schritt 3.2:** NoteEditorScreen anpassen
+
+```kotlin
+// NoteEditorScreen.kt
+
+// State abrufen
+val isOfflineMode by viewModel.isOfflineMode.collectAsState() // π NEU
+
+// Dialog anpassen
+if (showDeleteDialog) {
+ DeleteConfirmationDialog(
+ noteCount = 1,
+ isOfflineMode = isOfflineMode, // π v1.6.0: NEU
+ onDismiss = { showDeleteDialog = false },
+ onDeleteLocal = { ... },
+ onDeleteEverywhere = { ... }
+ )
+}
+```
+
+---
+
+## π§ Detaillierte Γnderungs-Matrix
+
+| Datei | Γnderungstyp | Beschreibung |
+|-------|--------------|--------------|
+| `strings.xml` | String hinzufΓΌgen | `delete_everywhere_offline_hint` (EN) |
+| `strings.xml` (de) | String hinzufΓΌgen | `delete_everywhere_offline_hint` (DE) |
+| `DeleteConfirmationDialog.kt` | Parameter + UI | `isOfflineMode` Parameter, grayed-out Button + Hint |
+| `MainScreen.kt` | Parameter ΓΌbergeben | `isOfflineMode = isOfflineMode` an Dialog |
+| `NoteEditorViewModel.kt` | StateFlow hinzufΓΌgen | `isOfflineMode: StateFlow` |
+| `NoteEditorScreen.kt` | State abrufen + ΓΌbergeben | collectAsState + an Dialog ΓΌbergeben |
+
+---
+
+## β
Akzeptanzkriterien
+
+1. **Offline-Modus aktiv:**
+ - [ ] "Γberall lΓΆschen" Button ist grau/deaktiviert
+ - [ ] Subtiler Hinweis-Text erscheint unter dem Button
+ - [ ] Button ist nicht anklickbar
+ - [ ] "Nur lokal lΓΆschen" funktioniert normal
+ - [ ] Kein Netzwerkverkehr bei LΓΆsch-Aktionen
+
+2. **Online-Modus (Offline-Modus deaktiviert):**
+ - [ ] Beide Buttons funktionieren normal
+ - [ ] Kein Hinweis-Text
+ - [ ] Verhalten unverΓ€ndert
+
+3. **Konsistenz:**
+ - [ ] UI-Pattern konsistent mit anderen v1.6.0 Offline-EinschrΓ€nkungen
+ - [ ] Farbgebung nutzt `MaterialTheme.colorScheme.tertiary` fΓΌr Hints
+
+4. **Stellen:**
+ - [ ] MainScreen (Batch-LΓΆschung mit Multi-Select)
+ - [ ] NoteEditorScreen (Einzelne Notiz lΓΆschen)
+
+---
+
+## π GeschΓ€tzter Aufwand
+
+| Phase | Aufwand | Dateien |
+|-------|---------|---------|
+| Phase 1: Dialog | ~30 Min | 3 Dateien |
+| Phase 2: MainScreen | ~10 Min | 1 Datei |
+| Phase 3: NoteEditor | ~20 Min | 2 Dateien |
+| **Gesamt** | **~1 Stunde** | **6 Dateien** |
+
+---
+
+## π§ͺ Test-Szenarien
+
+### Szenario 1: Einzelne Notiz lΓΆschen (Editor)
+1. Offline-Modus aktivieren
+2. Bestehende Notiz ΓΆffnen
+3. LΓΆschen-Button klicken
+4. **Erwartung:** "Γberall lΓΆschen" grau, Hint sichtbar
+5. "Nur lokal lΓΆschen" funktioniert
+
+### Szenario 2: Batch-LΓΆschung (Main Screen)
+1. Offline-Modus aktivieren
+2. Mehrere Notizen auswΓ€hlen (Long-Press + Tap)
+3. Papierkorb-Icon klicken
+4. **Erwartung:** "Γberall lΓΆschen" grau, Hint sichtbar
+5. "Nur lokal lΓΆschen" funktioniert
+
+### Szenario 3: Wechsel zwischen Modi
+1. Im Offline-Modus Dialog ΓΆffnen β Button deaktiviert
+2. Abbrechen, in Einstellungen Offline-Modus deaktivieren
+3. ZurΓΌck, Dialog erneut ΓΆffnen β Button aktiviert
+
+---
+
+## π Implementierungs-Reihenfolge
+
+```
+1. βββββββββββββββββββββββββββββββββββ
+ β String-Ressourcen hinzufΓΌgen β β Start hier
+ βββββββββββββββββ¬ββββββββββββββββββ
+ βΌ
+2. βββββββββββββββββββββββββββββββββββ
+ β DeleteConfirmationDialog.kt β β Kern-Γnderung
+ βββββββββββββββββ¬ββββββββββββββββββ
+ βΌ
+3. βββββββββββββββββββββββββββββββββββ
+ β MainScreen.kt β β Einfach (State vorhanden)
+ βββββββββββββββββ¬ββββββββββββββββββ
+ βΌ
+4. βββββββββββββββββββββββββββββββββββ
+ β NoteEditorViewModel.kt β β StateFlow hinzufΓΌgen
+ βββββββββββββββββ¬ββββββββββββββββββ
+ βΌ
+5. βββββββββββββββββββββββββββββββββββ
+ β NoteEditorScreen.kt β β State abrufen + ΓΌbergeben
+ βββββββββββββββββββββββββββββββββββ
+```
+
+---
+
+## π AbhΓ€ngigkeiten
+
+- Keine externen AbhΓ€ngigkeiten
+- Nutzt bestehende v1.6.0 Offline-Mode Infrastruktur
+- Konsistent mit Design-Pattern aus `MarkdownSettingsScreen.kt` und `BackupSettingsScreen.kt`
+
+---
+
+## π Hinweise
+
+- Der `isOfflineMode` State in `MainViewModel` wird bereits reaktiv via `StateFlow` verwaltet
+- `refreshOfflineModeState()` wird in `ComposeMainActivity.onResume()` aufgerufen
+- Das gleiche Pattern wird in `NoteEditorViewModel` repliziert (einmalige Initialisierung ausreichend, da Editor-Lebenszyklus kurz ist)
diff --git a/fastlane/metadata/android/de-DE/changelogs/14.txt b/fastlane/metadata/android/de-DE/changelogs/14.txt
new file mode 100644
index 0000000..676ad05
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/14.txt
@@ -0,0 +1,6 @@
+β’ NEU: Konfigurierbare Sync-Trigger - Jeden einzeln aktivieren/deaktivieren
+β’ NEU: Offline-Modus - Alle Netzwerkfunktionen mit einem Schalter aus
+β’ 5 Trigger: onSave, onResume, WiFi, Periodic (15/30/60 Min), Boot
+β’ Smarte Defaults: Nur ereignisbasiert aktiv (~0.2%/Tag Akku)
+β’ Periodischer Sync optional (Standard: AUS)
+β’ Verschiedene Fixes und UI-Verbesserungen
\ No newline at end of file
diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt
index b06d331..50e7a90 100644
--- a/fastlane/metadata/android/de-DE/full_description.txt
+++ b/fastlane/metadata/android/de-DE/full_description.txt
@@ -38,11 +38,13 @@ MULTI-DEVICE SYNC:
SYNCHRONISATION:
β’ UnterstΓΌtzt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
-β’ Automatische WiFi-Sync: Synchronisiert automatisch wenn du ein beliebiges WLAN betrittst (wenn Server erreichbar ist)
-β’ Konfigurierbares Interval: 15, 30 oder 60 Minuten
+β’ Konfigurierbare Sync-Trigger: WΓ€hle einzeln, wann synchronisiert wird
+β’ 5 Trigger: onSave (nach dem Speichern), onResume (beim Γffnen), WiFi-Connect, Periodic (15/30/60 Min), Boot
+β’ Offline-Modus: Alle Netzwerkfunktionen mit einem Schalter deaktivieren
+β’ Smarte Defaults: nur ereignisbasierte Trigger aktiv (~0.2%/Tag Akku)
+β’ Periodischer Sync optional (Standard: AUS)
β’ Optimierte Performance: ΓΌberspringt unverΓ€nderte Dateien (~2-3s Sync-Zeit)
β’ E-Tag Caching fΓΌr 20x schnellere "keine Γnderungen" Checks
-β’ Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
β’ Silent-Sync Modus: kein Banner bei Auto-Sync
β’ Doze Mode optimiert fΓΌr zuverlΓ€ssige Background-Syncs
β’ Manuelle Synchronisation jederzeit mΓΆglich
diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png
index 4be691d..53ba65d 100644
Binary files a/fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png and b/fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png b/fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png
new file mode 100644
index 0000000..2a77315
Binary files /dev/null and b/fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/en-US/changelogs/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt
new file mode 100644
index 0000000..d59bb90
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/14.txt
@@ -0,0 +1,6 @@
+β’ NEW: Configurable Sync Triggers - Enable/disable each individually
+β’ NEW: Offline Mode - Disable all network features with one switch
+β’ 5 triggers: onSave, onResume, WiFi, Periodic (15/30/60 min), Boot
+β’ Smart defaults: Event-driven only (~0.2%/day battery)
+β’ Periodic sync optional (default: OFF)
+β’ Various fixes and UI improvements
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 1871db1..6f8ca29 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -38,11 +38,13 @@ MULTI-DEVICE SYNC:
SYNCHRONIZATION:
β’ Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
-β’ Automatic WiFi sync: synchronizes whenever you join any WiFi network (if server is reachable)
-β’ Configurable interval: 15, 30, or 60 minutes
+β’ Configurable Sync Triggers: Choose individually when to sync
+β’ 5 triggers: onSave (after saving), onResume (on open), WiFi-Connect, Periodic (15/30/60 min), Boot
+β’ Offline Mode: Disable all network features with one switch
+β’ Smart defaults: event-driven triggers only (~0.2%/day battery)
+β’ Periodic sync optional (default: OFF)
β’ Optimized performance: skips unchanged files (~2-3s sync time)
β’ E-Tag caching for 20x faster "no changes" checks
-β’ Measured battery consumption: only ~0.4% per day (at 30min)
β’ Silent-Sync mode: no banner during auto-sync
β’ Doze Mode optimized for reliable background syncs
β’ Manual synchronization available anytime
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
index 4be691d..53ba65d 100644
Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
new file mode 100644
index 0000000..2a77315
Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ
diff --git a/metadata/dev.dettmer.simplenotes.yml b/metadata/dev.dettmer.simplenotes.yml
deleted file mode 100644
index e132912..0000000
--- a/metadata/dev.dettmer.simplenotes.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-Categories:
- - Writing
-License: MIT
-AuthorName: inventory69
-AuthorEmail: admin@dettmer.dev
-AuthorWebSite: https://dettmer.dev
-SourceCode: https://github.com/inventory69/simple-notes-sync
-IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
-Changelog: https://github.com/inventory69/simple-notes-sync/releases
-
-AutoName: Simple Notes
-
-RepoType: git
-Repo: https://github.com/inventory69/simple-notes-sync.git
-Binaries: https://github.com/inventory69/simple-notes-sync/releases/download/v%v/simple-notes-sync-v%v-fdroid.apk
-
-Builds:
- - versionName: 1.5.0
- versionCode: 13
- commit: 65395142fab487e0a286cc5dfe3cf8b76652379d
- subdir: android/app
- gradle:
- - fdroid
-
-AllowedAPKSigningKeys: 42a1c613bbc673045af3dc8191bf9cb6456ee44c7dce40c7cfb566facb69f16a
-
-AutoUpdateMode: Version
-UpdateCheckMode: Tags
-CurrentVersion: 1.5.0
-CurrentVersionCode: 13