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

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,9 +73,11 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) }
// Check server status on load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
// Check server status on load (only if not in offline mode)
LaunchedEffect(offlineMode) {
if (!offlineMode) {
viewModel.checkServerStatus()
}
}
SettingsScaffold(
@@ -83,99 +91,168 @@ fun ServerSettingsScreen(
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Verbindungstyp
Text(
text = stringResource(R.string.server_connection_type),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
// ═══════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
// ═══════════════════════════════════════════════════════════════
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setOfflineMode(!offlineMode) },
colors = CardDefaults.cardColors(
containerColor = if (offlineMode) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
) {
FilterChip(
selected = !isHttps,
onClick = { viewModel.updateProtocol(false) },
label = { Text(stringResource(R.string.server_connection_http)) },
modifier = Modifier.weight(1f)
)
FilterChip(
selected = isHttps,
onClick = { viewModel.updateProtocol(true) },
label = { Text(stringResource(R.string.server_connection_https)) },
modifier = Modifier.weight(1f)
)
}
Text(
text = if (!isHttps) {
stringResource(R.string.server_connection_http_hint)
} else {
stringResource(R.string.server_connection_https_hint)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
)
// Server-Adresse
OutlinedTextField(
value = serverUrl,
onValueChange = { viewModel.updateServerUrl(it) },
label = { Text(stringResource(R.string.server_address)) },
supportingText = { Text(stringResource(R.string.server_address_hint)) },
leadingIcon = { Icon(Icons.Default.Language, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(modifier = Modifier.height(12.dp))
// Benutzername
OutlinedTextField(
value = username,
onValueChange = { viewModel.updateUsername(it) },
label = { Text(stringResource(R.string.username)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
// Passwort
OutlinedTextField(
value = password,
onValueChange = { viewModel.updatePassword(it) },
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (passwordVisible) {
stringResource(R.string.server_password_hide)
} else {
stringResource(R.string.server_password_show)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.server_offline_mode_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.server_offline_mode_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Switch(
checked = offlineMode,
onCheckedChange = { viewModel.setOfflineMode(it) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// ═══════════════════════════════════════════════════════════════
// Server Configuration (grayed out when offline mode)
// ═══════════════════════════════════════════════════════════════
val fieldsEnabled = !offlineMode
val fieldsAlpha = if (offlineMode) 0.5f else 1f
Column(modifier = Modifier.alpha(fieldsAlpha)) {
// Verbindungstyp
Text(
text = stringResource(R.string.server_connection_type),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = !isHttps,
onClick = { viewModel.updateProtocol(false) },
label = { Text(stringResource(R.string.server_connection_http)) },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
)
FilterChip(
selected = isHttps,
onClick = { viewModel.updateProtocol(true) },
label = { Text(stringResource(R.string.server_connection_https)) },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
)
}
Text(
text = if (!isHttps) {
stringResource(R.string.server_connection_http_hint)
} else {
stringResource(R.string.server_connection_https_hint)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
)
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
OutlinedTextField(
value = serverHost, // Only host part is editable
onValueChange = { viewModel.updateServerHost(it) },
label = { Text(stringResource(R.string.server_address)) },
supportingText = { Text(stringResource(R.string.server_address_hint)) },
prefix = {
// Protocol prefix is displayed but not editable
Text(
text = if (isHttps) "https://" else "http://",
style = MaterialTheme.typography.bodyLarge,
color = if (fieldsEnabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
)
},
leadingIcon = { Icon(Icons.Default.Language, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
)
Spacer(modifier = Modifier.height(12.dp))
// Benutzername
OutlinedTextField(
value = username,
onValueChange = { viewModel.updateUsername(it) },
label = { Text(stringResource(R.string.username)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = fieldsEnabled
)
Spacer(modifier = Modifier.height(12.dp))
// Passwort
OutlinedTextField(
value = password,
onValueChange = { viewModel.updatePassword(it) },
label = { Text(stringResource(R.string.password)) },
leadingIcon = { Icon(Icons.Default.Lock, null) },
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = if (passwordVisible) {
stringResource(R.string.server_password_hide)
} else {
stringResource(R.string.server_password_show)
}
)
}
},
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
}
Spacer(modifier = Modifier.height(16.dp))
@@ -196,16 +273,18 @@ fun ServerSettingsScreen(
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
Text(
text = when (serverStatus) {
is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.server_status_offline_mode)
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured)
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
else -> stringResource(R.string.server_status_unknown)
},
color = when (serverStatus) {
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
@@ -214,13 +293,16 @@ fun ServerSettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
// Action Buttons (disabled in offline mode)
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.alpha(fieldsAlpha),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.test_connection))
@@ -228,7 +310,7 @@ fun ServerSettingsScreen(
Button(
onClick = { viewModel.syncNow() },
enabled = !isSyncing,
enabled = fieldsEnabled && !isSyncing,
modifier = Modifier.weight(1f)
) {
if (isSyncing) {

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) {
stringResource(R.string.settings_markdown_auto_on)
} else {
stringResource(R.string.settings_markdown_auto_off)
},
subtitle = if (isServerConfiguredForMarkdown) {
if (markdownAutoSync) {
stringResource(R.string.settings_markdown_auto_on)
} else {
stringResource(R.string.settings_markdown_auto_off)
}
} else null,
statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Markdown) }
)
}

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,55 +66,137 @@ fun SyncSettingsScreen(
) {
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Info
SettingsInfoCard(
text = stringResource(R.string.sync_auto_sync_info)
// 🌟 v1.6.0: Offline Mode Warning if server not configured
if (!isServerConfigured) {
SettingsInfoCard(
text = stringResource(R.string.sync_offline_mode_message),
isWarning = true
)
Button(
onClick = onNavigateToServerSettings,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(stringResource(R.string.sync_offline_mode_button))
}
Spacer(modifier = Modifier.height(8.dp))
}
// ═══════════════════════════════════════════════════════════════
// SOFORT-SYNC Section
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
// onSave Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_save_title),
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
checked = triggerOnSave,
onCheckedChange = { viewModel.setTriggerOnSave(it) },
icon = Icons.Default.Save,
enabled = isServerConfigured
)
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Toggle
// onResume Trigger
SettingsSwitch(
title = stringResource(R.string.sync_auto_sync_enabled),
checked = autoSyncEnabled,
onCheckedChange = { viewModel.setAutoSync(it) },
icon = Icons.Default.Sync
title = stringResource(R.string.sync_trigger_on_resume_title),
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
checked = triggerOnResume,
onCheckedChange = { viewModel.setTriggerOnResume(it) },
icon = Icons.Default.PhonelinkRing,
enabled = isServerConfigured
)
SettingsDivider()
// Sync Interval Section
SettingsSectionHeader(text = stringResource(R.string.sync_interval_section))
// ═══════════════════════════════════════════════════════════════
// HINTERGRUND-SYNC Section
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
// WiFi-Connect Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_wifi_connect_title),
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
checked = triggerWifiConnect,
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
icon = Icons.Default.Wifi,
enabled = isServerConfigured
)
// Periodic Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_periodic_title),
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
checked = triggerPeriodic,
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
icon = Icons.Default.Schedule,
enabled = isServerConfigured
)
// Periodic Interval Selection (only visible if periodic trigger is enabled)
if (triggerPeriodic && isServerConfigured) {
Spacer(modifier = Modifier.height(8.dp))
val intervalOptions = listOf(
RadioOption(
value = 15L,
title = stringResource(R.string.sync_interval_15min_title),
subtitle = null
),
RadioOption(
value = 30L,
title = stringResource(R.string.sync_interval_30min_title),
subtitle = null
),
RadioOption(
value = 60L,
title = stringResource(R.string.sync_interval_60min_title),
subtitle = null
)
)
SettingsRadioGroup(
options = intervalOptions,
selectedValue = syncInterval,
onValueSelected = { viewModel.setSyncInterval(it) }
)
Spacer(modifier = Modifier.height(8.dp))
}
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// ADVANCED Section (Boot Sync)
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
// Boot Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_boot_title),
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
checked = triggerBoot,
onCheckedChange = { viewModel.setTriggerBoot(it) },
icon = Icons.Default.SettingsInputAntenna,
enabled = isServerConfigured
)
SettingsDivider()
// Manual Sync Info
val manualHintText = if (isServerConfigured) {
stringResource(R.string.sync_manual_hint)
} else {
stringResource(R.string.sync_manual_hint_disabled)
}
SettingsInfoCard(
text = stringResource(R.string.sync_interval_info)
)
Spacer(modifier = Modifier.height(8.dp))
// Interval Radio Group
val intervalOptions = listOf(
RadioOption(
value = 15L,
title = stringResource(R.string.sync_interval_15min_title),
subtitle = stringResource(R.string.sync_interval_15min_subtitle)
),
RadioOption(
value = 30L,
title = stringResource(R.string.sync_interval_30min_title),
subtitle = stringResource(R.string.sync_interval_30min_subtitle)
),
RadioOption(
value = 60L,
title = stringResource(R.string.sync_interval_60min_title),
subtitle = stringResource(R.string.sync_interval_60min_subtitle)
)
)
SettingsRadioGroup(
options = intervalOptions,
selectedValue = syncInterval,
onValueSelected = { viewModel.setSyncInterval(it) }
text = manualHintText
)
Spacer(modifier = Modifier.height(16.dp))

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 -->
<!-- ============================= -->