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

- Notizliste + Sync-Status Notiz bearbeiten Checkliste bearbeiten Einstellungen Server-Einstellungen - Sync-Status + Sync-Einstellungen

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

- Notes list + Sync status Edit note Edit checklist Settings Server settings - Sync status + Sync settings

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