Release v1.6.0: Configurable Sync Triggers + Offline Mode

- NEW: Configurable sync triggers (onSave, onResume, WiFi, Periodic, Boot)
- NEW: Offline mode toggle to disable all network features
- Various fixes and UI improvements
- Version bumped to 1.6.0 (code 14)
This commit is contained in:
inventory69
2026-01-19 23:31:25 +01:00
parent ef6e939567
commit 1d010d0034
42 changed files with 1530 additions and 306 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -18,12 +18,12 @@
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Notizliste">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png" width="250" alt="Sync-Status">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Checkliste bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server-Einstellungen">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/6.png" width="250" alt="Sync-Status">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync-Einstellungen">
</p>
---
@@ -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

View File

@@ -18,12 +18,12 @@
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Notes list">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p>
---
@@ -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

View File

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

View File

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

View File

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

View File

@@ -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<String?>(null) }
val scope = rememberCoroutineScope()
@@ -233,6 +236,7 @@ fun NoteEditorScreen(
if (showDeleteDialog) {
DeleteConfirmationDialog(
noteCount = 1,
isOfflineMode = isOfflineMode,
onDismiss = { showDeleteDialog = false },
onDeleteLocal = {
showDeleteDialog = false

View File

@@ -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<List<ChecklistItemState>>(emptyList())
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
// 🌟 v1.6.0: Offline Mode State
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _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<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG)
.build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
}
}
// ═══════════════════════════════════════════════════════════════════════════

View File

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

View File

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

View File

@@ -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<Boolean> = _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<android.app.Application>().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://"
}

View File

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

View File

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

View File

@@ -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<String> = _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<Boolean> = _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<String> = _serverHost.asStateFlow()
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
val serverUrl: StateFlow<String> = 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<String> = _username.asStateFlow()
@@ -57,13 +80,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow<String> = _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<Boolean> = _isHttps.asStateFlow()
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
val serverStatus: StateFlow<ServerStatus> = _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<Boolean> = _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<Long> = _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<Boolean> = _triggerOnSave.asStateFlow()
private val _triggerOnResume = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
)
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
private val _triggerWifiConnect = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
)
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
private val _triggerPeriodic = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
)
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
private val _triggerBoot = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
)
val triggerBoot: StateFlow<Boolean> = _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<android.app.Application>().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()

View File

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

View File

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

View File

@@ -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>(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))
}
}

View File

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

View File

@@ -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,10 +73,12 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) }
// Check server status on load
LaunchedEffect(Unit) {
// Check server status on load (only if not in offline mode)
LaunchedEffect(offlineMode) {
if (!offlineMode) {
viewModel.checkServerStatus()
}
}
SettingsScaffold(
title = stringResource(R.string.server_settings_title),
@@ -83,6 +91,57 @@ fun ServerSettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// ═══════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
// ═══════════════════════════════════════════════════════════════
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setOfflineMode(!offlineMode) },
colors = CardDefaults.cardColors(
containerColor = if (offlineMode) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
) {
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
)
}
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),
@@ -98,12 +157,14 @@ fun ServerSettingsScreen(
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)
)
}
@@ -119,15 +180,28 @@ fun ServerSettingsScreen(
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
)
// Server-Adresse
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
OutlinedTextField(
value = serverUrl,
onValueChange = { viewModel.updateServerUrl(it) },
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)
)
@@ -140,7 +214,8 @@ fun ServerSettingsScreen(
label = { Text(stringResource(R.string.username)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
singleLine = true,
enabled = fieldsEnabled
)
Spacer(modifier = Modifier.height(12.dp))
@@ -174,8 +249,10 @@ fun ServerSettingsScreen(
},
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) {

View File

@@ -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) {
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) }
)
}

View File

@@ -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,48 +66,96 @@ fun SyncSettingsScreen(
) {
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Info
// 🌟 v1.6.0: Offline Mode Warning if server not configured
if (!isServerConfigured) {
SettingsInfoCard(
text = stringResource(R.string.sync_auto_sync_info)
text = stringResource(R.string.sync_offline_mode_message),
isWarning = true
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onNavigateToServerSettings,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(stringResource(R.string.sync_offline_mode_button))
}
// Auto-Sync Toggle
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_auto_sync_enabled),
checked = autoSyncEnabled,
onCheckedChange = { viewModel.setAutoSync(it) },
icon = Icons.Default.Sync
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
)
// onResume Trigger
SettingsSwitch(
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
// ═══════════════════════════════════════════════════════════════
SettingsInfoCard(
text = stringResource(R.string.sync_interval_info)
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))
// 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)
subtitle = null
),
RadioOption(
value = 30L,
title = stringResource(R.string.sync_interval_30min_title),
subtitle = stringResource(R.string.sync_interval_30min_subtitle)
subtitle = null
),
RadioOption(
value = 60L,
title = stringResource(R.string.sync_interval_60min_title),
subtitle = stringResource(R.string.sync_interval_60min_subtitle)
subtitle = null
)
)
@@ -100,6 +165,40 @@ fun SyncSettingsScreen(
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 = manualHintText
)
Spacer(modifier = Modifier.height(16.dp))
}
}

View File

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

View File

@@ -65,6 +65,7 @@
<string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string>
<string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string>
<string name="delete_everywhere">Überall löschen (auch Server)</string>
<string name="delete_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</string>
<string name="delete_local_only">Nur lokal löschen</string>
<string name="delete">Löschen</string>
<string name="cancel">Abbrechen</string>
@@ -135,9 +136,13 @@
<string name="settings_server_status_unreachable">❌ Nicht erreichbar</string>
<string name="settings_server_status_checking">🔍 Prüfe…</string>
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="settings_server_status_offline_mode">📴 Offline-Modus</string>
<string name="settings_sync">Sync-Einstellungen</string>
<string name="settings_sync_auto_on">Auto-Sync: An • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Aus</string>
<string name="settings_sync_offline_mode">📴 Offline-Modus</string>
<string name="settings_sync_manual_only">Nur manueller Sync</string>
<string name="settings_sync_triggers_active">%d Trigger aktiv</string>
<string name="settings_interval_15min">15 Min</string>
<string name="settings_interval_30min">30 Min</string>
<string name="settings_interval_60min">60 Min</string>
@@ -173,7 +178,10 @@
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
<string name="server_status_checking">🔍 Prüfe…</string>
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="server_status_offline_mode">📴 Offline-Modus aktiv</string>
<string name="server_status_unknown">❓ Unbekannt</string>
<string name="server_offline_mode_title">📴 Offline-Modus</string>
<string name="server_offline_mode_subtitle">Alle Netzwerkfunktionen deaktivieren</string>
<string name="test_connection">Verbindung testen</string>
<string name="sync_now">Jetzt synchronisieren</string>
@@ -196,6 +204,33 @@
<!-- Legacy -->
<string name="auto_sync_info"> 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)</string>
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
<string name="sync_section_instant">📲 Sofort-Sync</string>
<string name="sync_section_background">📡 Hintergrund-Sync</string>
<string name="sync_section_advanced">⚙️ Erweitert</string>
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
<string name="sync_trigger_on_resume_title">Beim App-Start</string>
<string name="sync_trigger_on_resume_subtitle">Sync wenn die App geöffnet wird</string>
<string name="sync_trigger_wifi_connect_title">Bei WiFi-Verbindung</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync wenn WiFi verbunden wird</string>
<string name="sync_trigger_periodic_title">Automatisch alle X Minuten</string>
<string name="sync_trigger_periodic_subtitle">Regelmäßiger Hintergrund-Sync</string>
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
<string name="sync_offline_mode_title">Offline-Modus</string>
<string name="sync_offline_mode_message">Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.</string>
<string name="sync_offline_mode_button">Server einrichten</string>
<!-- ============================= -->
<!-- SETTINGS - MARKDOWN -->
<!-- ============================= -->

View File

@@ -66,6 +66,7 @@
<string name="delete_note_message">How do you want to delete this note?</string>
<string name="delete_notes_message">How do you want to delete these %d notes?</string>
<string name="delete_everywhere">Delete everywhere (also server)</string>
<string name="delete_everywhere_offline_hint">Not available in offline mode</string>
<string name="delete_local_only">Delete local only</string>
<string name="delete">Delete</string>
<string name="cancel">Cancel</string>
@@ -136,9 +137,13 @@
<string name="settings_server_status_unreachable">❌ Not reachable</string>
<string name="settings_server_status_checking">🔍 Checking…</string>
<string name="settings_server_status_not_configured">⚠️ Not configured</string>
<string name="settings_server_status_offline_mode">📴 Offline Mode</string>
<string name="settings_sync">Sync Settings</string>
<string name="settings_sync_auto_on">Auto-Sync: On • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Off</string>
<string name="settings_sync_offline_mode">📴 Offline Mode</string>
<string name="settings_sync_manual_only">Manual sync only</string>
<string name="settings_sync_triggers_active">%d triggers active</string>
<string name="settings_interval_15min">15 min</string>
<string name="settings_interval_30min">30 min</string>
<string name="settings_interval_60min">60 min</string>
@@ -174,7 +179,10 @@
<string name="server_status_unreachable">❌ Not reachable</string>
<string name="server_status_checking">🔍 Checking…</string>
<string name="server_status_not_configured">⚠️ Not configured</string>
<string name="server_status_offline_mode">📴 Offline mode active</string>
<string name="server_status_unknown">❓ Unknown</string>
<string name="server_offline_mode_title">📴 Offline Mode</string>
<string name="server_offline_mode_subtitle">Disable all network features</string>
<string name="test_connection">Test Connection</string>
<string name="sync_now">Sync now</string>
@@ -197,6 +205,33 @@
<!-- Legacy -->
<string name="auto_sync_info"> 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)</string>
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
<string name="sync_section_instant">📲 Instant Sync</string>
<string name="sync_section_background">📡 Background Sync</string>
<string name="sync_section_advanced">⚙️ Advanced</string>
<string name="sync_trigger_on_save_title">After Saving</string>
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
<string name="sync_trigger_on_resume_title">On App Start</string>
<string name="sync_trigger_on_resume_subtitle">Sync when the app is opened</string>
<string name="sync_trigger_wifi_connect_title">On WiFi Connection</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync when WiFi is connected</string>
<string name="sync_trigger_periodic_title">Automatically every X minutes</string>
<string name="sync_trigger_periodic_subtitle">Regular background sync</string>
<string name="sync_trigger_boot_title">After Device Restart</string>
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
<string name="sync_offline_mode_title">Offline Mode</string>
<string name="sync_offline_mode_message">You are using the app in offline mode. Set up a server to synchronize notes.</string>
<string name="sync_offline_mode_button">Set Up Server</string>
<!-- ============================= -->
<!-- SETTINGS - MARKDOWN -->
<!-- ============================= -->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Boolean>`
---
## 📐 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
<!-- values/strings.xml -->
<string name="delete_everywhere_offline_hint">Not available in offline mode</string>
<!-- values-de/strings.xml -->
<string name="delete_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</string>
```
**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<Boolean> = _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<Boolean>` |
| `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)

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

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