v1.5.0: Jetpack Compose Settings Redesign + Fixes

Features:
-  Complete Settings UI redesign with Jetpack Compose
- 🎨 Material 3 Design with Dynamic Colors (Material You)
- 📊 6 logical settings groups in separate screens:
  * Server Settings (URL, Credentials, Connection Test)
  * Sync Settings (Auto-Sync, Interval 15/30/60 min)
  * Markdown Desktop Integration (Auto-Sync for .md files)
  * Backup & Restore (Local/Server)
  * About this App (Version, GitHub, License)
  * Debug & Diagnostics (File Logging, Log Export)

Bugfixes (ported from old SettingsActivity):
- 🔧 Fix #1: Server URL prefix (http://|https://) auto-set on init
- 🔧 Fix #2: Battery optimization dialog on Auto-Sync enable
- 🔧 Fix #3: Markdown initial export on feature activation

Implementation Details:
- SettingsViewModel with Kotlin Flows state management
- SettingsEvent system for Activity-level actions (dialogs, intents)
- Reusable Compose components (SettingsCard, Switch, RadioGroup, etc.)
- Progress dialog for markdown initial export
- Edge-to-edge display with system bar handling
- Navigation Compose for screen transitions with back button

Breaking Changes:
- Old SettingsActivity.kt (1147 lines) no longer used
  (Can be removed in future as legacy code)
This commit is contained in:
inventory69
2026-01-15 11:02:38 +01:00
parent b052fb0f0a
commit 64b2cfaf78
21 changed files with 2591 additions and 4 deletions

View File

@@ -43,11 +43,17 @@
android:windowSoftInputMode="adjustResize"
android:parentActivityName=".MainActivity" />
<!-- Settings Activity -->
<!-- Settings Activity (Legacy - XML-based) -->
<activity
android:name=".SettingsActivity"
android:parentActivityName=".MainActivity" />
<!-- Settings Activity v1.5.0 (Jetpack Compose) -->
<activity
android:name=".ui.settings.ComposeSettingsActivity"
android:parentActivityName=".MainActivity"
android:theme="@style/Theme.SimpleNotes" />
<!-- Boot Receiver - Startet WorkManager nach Reboot -->
<receiver
android:name=".sync.BootReceiver"

View File

@@ -638,7 +638,8 @@ class MainActivity : AppCompatActivity() {
}
private fun openSettings() {
val intent = Intent(this, SettingsActivity::class.java)
// v1.5.0: Use new Jetpack Compose Settings
val intent = Intent(this, dev.dettmer.simplenotes.ui.settings.ComposeSettingsActivity::class.java)
@Suppress("DEPRECATION")
startActivityForResult(intent, REQUEST_SETTINGS)
}

View File

@@ -0,0 +1,208 @@
package dev.dettmer.simplenotes.ui.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.rememberNavController
import com.google.android.material.color.DynamicColors
import dev.dettmer.simplenotes.SimpleNotesApplication
import dev.dettmer.simplenotes.utils.Logger
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/**
* Settings Activity with Jetpack Compose UI
* v1.5.0: Complete Settings Redesign with grouped screens
*
* Replaces the old 1147-line SettingsActivity.kt with a modern
* Compose-based implementation featuring:
* - 6 logical settings groups as separate screens
* - Material 3 Design with Dynamic Colors (Material You)
* - Navigation with back button in each screen
* - Clean separation of concerns with SettingsViewModel
*/
class ComposeSettingsActivity : ComponentActivity() {
companion object {
private const val TAG = "ComposeSettingsActivity"
}
private val viewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Apply Dynamic Colors for Material You (Android 12+)
DynamicColors.applyToActivityIfAvailable(this)
// Enable edge-to-edge display
enableEdgeToEdge()
// Collect events from ViewModel (for Activity-level actions)
collectViewModelEvents()
setContent {
SimpleNotesSettingsTheme {
val navController = rememberNavController()
val context = LocalContext.current
// Toast handling from ViewModel
LaunchedEffect(Unit) {
viewModel.showToast.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
SettingsNavHost(
navController = navController,
viewModel = viewModel,
onFinish = {
setResult(RESULT_OK)
finish()
}
)
}
}
}
/**
* Collect events from ViewModel for Activity-level actions
* v1.5.0: Ported from old SettingsActivity
*/
private fun collectViewModelEvents() {
lifecycleScope.launch {
viewModel.events.collect { event ->
when (event) {
is SettingsViewModel.SettingsEvent.RequestBatteryOptimization -> {
checkBatteryOptimization()
}
is SettingsViewModel.SettingsEvent.RestartNetworkMonitor -> {
restartNetworkMonitor()
}
}
}
}
}
/**
* Check if battery optimization is disabled for this app
* v1.5.0: Ported from old SettingsActivity
*/
private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
showBatteryOptimizationDialog()
}
}
/**
* Show dialog asking user to disable battery optimization
* v1.5.0: Ported from old SettingsActivity
*/
private fun showBatteryOptimizationDialog() {
AlertDialog.Builder(this)
.setTitle("Hintergrund-Synchronisation")
.setMessage(
"Damit die App im Hintergrund synchronisieren kann, " +
"muss die Akku-Optimierung deaktiviert werden.\n\n" +
"Bitte wähle 'Nicht optimieren' für Simple Notes."
)
.setPositiveButton("Einstellungen öffnen") { _, _ ->
openBatteryOptimizationSettings()
}
.setNegativeButton("Später") { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
/**
* Open system battery optimization settings
* v1.5.0: Ported from old SettingsActivity
*/
private fun openBatteryOptimizationSettings() {
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
// Fallback: Open general battery settings
try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent)
} catch (e2: Exception) {
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
Toast.makeText(this, "Bitte Akku-Optimierung manuell deaktivieren", Toast.LENGTH_LONG).show()
}
}
}
/**
* Restart the network monitor after sync settings change
* v1.5.0: Ported from old SettingsActivity
*/
private fun restartNetworkMonitor() {
try {
val app = application as SimpleNotesApplication
Logger.d(TAG, "🔄 Restarting NetworkMonitor with new settings")
app.networkMonitor.stopMonitoring()
app.networkMonitor.startMonitoring()
Logger.d(TAG, "✅ NetworkMonitor restarted successfully")
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to restart NetworkMonitor: ${e.message}")
}
}
}
/**
* Material 3 Theme with Dynamic Colors support
*/
@Composable
fun SimpleNotesSettingsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val colorScheme = when {
// Dynamic colors are available on Android 12+
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
}
// Fallback to static Material 3 colors
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,85 @@
package dev.dettmer.simplenotes.ui.settings
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
import dev.dettmer.simplenotes.ui.settings.screens.SettingsMainScreen
import dev.dettmer.simplenotes.ui.settings.screens.SyncSettingsScreen
/**
* Settings navigation host with all routes
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsNavHost(
navController: NavHostController,
viewModel: SettingsViewModel,
onFinish: () -> Unit
) {
NavHost(
navController = navController,
startDestination = SettingsRoute.Main.route
) {
// Main Settings Overview
composable(SettingsRoute.Main.route) {
SettingsMainScreen(
viewModel = viewModel,
onNavigate = { route -> navController.navigate(route.route) },
onBack = onFinish
)
}
// Server Settings
composable(SettingsRoute.Server.route) {
ServerSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Sync Settings
composable(SettingsRoute.Sync.route) {
SyncSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Markdown Settings
composable(SettingsRoute.Markdown.route) {
MarkdownSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// Backup Settings
composable(SettingsRoute.Backup.route) {
BackupSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
// About Screen
composable(SettingsRoute.About.route) {
AboutScreen(
onBack = { navController.popBackStack() }
)
}
// Debug Settings
composable(SettingsRoute.Debug.route) {
DebugSettingsScreen(
viewModel = viewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -0,0 +1,15 @@
package dev.dettmer.simplenotes.ui.settings
/**
* Navigation routes for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign
*/
sealed class SettingsRoute(val route: String) {
data object Main : SettingsRoute("settings_main")
data object Server : SettingsRoute("settings_server")
data object Sync : SettingsRoute("settings_sync")
data object Markdown : SettingsRoute("settings_markdown")
data object Backup : SettingsRoute("settings_backup")
data object About : SettingsRoute("settings_about")
data object Debug : SettingsRoute("settings_debug")
}

View File

@@ -0,0 +1,493 @@
package dev.dettmer.simplenotes.ui.settings
import android.app.Application
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
/**
* ViewModel for Settings screens
* v1.5.0: Jetpack Compose Settings Redesign
*
* Manages all settings state and actions across the Settings navigation graph.
*/
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
companion object {
private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000
}
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val backupManager = BackupManager(application)
// ═══════════════════════════════════════════════════════════════════════
// Server Settings State
// ═══════════════════════════════════════════════════════════════════════
// 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()
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow<String> = _username.asStateFlow()
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()
// ═══════════════════════════════════════════════════════════════════════
// Events (for Activity-level actions like dialogs, intents)
// ═══════════════════════════════════════════════════════════════════════
private val _events = MutableSharedFlow<SettingsEvent>()
val events: SharedFlow<SettingsEvent> = _events.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Export Progress State
// ═══════════════════════════════════════════════════════════════════════
private val _markdownExportProgress = MutableStateFlow<MarkdownExportProgress?>(null)
val markdownExportProgress: StateFlow<MarkdownExportProgress?> = _markdownExportProgress.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Sync Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false))
val autoSyncEnabled: StateFlow<Boolean> = _autoSyncEnabled.asStateFlow()
private val _syncInterval = MutableStateFlow(
prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
)
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _markdownAutoSync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) &&
prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
)
val markdownAutoSync: StateFlow<Boolean> = _markdownAutoSync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// Debug Settings State
// ═══════════════════════════════════════════════════════════════════════
private val _fileLoggingEnabled = MutableStateFlow(
prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
)
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════
// UI State
// ═══════════════════════════════════════════════════════════════════════
private val _isSyncing = MutableStateFlow(false)
val isSyncing: StateFlow<Boolean> = _isSyncing.asStateFlow()
private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow()
// ═══════════════════════════════════════════════════════════════════════
// Server Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun updateServerUrl(url: String) {
_serverUrl.value = url
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
saveServerSettings()
}
fun updateUsername(value: String) {
_username.value = value
saveServerSettings()
}
fun updatePassword(value: String) {
_password.value = value
saveServerSettings()
}
private fun saveServerSettings() {
prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, _serverUrl.value)
putString(Constants.KEY_USERNAME, _username.value)
putString(Constants.KEY_PASSWORD, _password.value)
apply()
}
}
fun testConnection() {
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
try {
val syncService = WebDavSyncService(getApplication())
val result = syncService.testConnection()
_serverStatus.value = if (result.isSuccess) {
ServerStatus.Reachable
} else {
ServerStatus.Unreachable(result.errorMessage)
}
emitToast(if (result.isSuccess) "✅ Verbindung erfolgreich!" else "${result.errorMessage}")
} catch (e: Exception) {
_serverStatus.value = ServerStatus.Unreachable(e.message)
emitToast("❌ Fehler: ${e.message}")
}
}
}
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://") {
_serverStatus.value = ServerStatus.NotConfigured
return
}
viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) {
try {
val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = CONNECTION_TIMEOUT_MS
val code = connection.responseCode
connection.disconnect()
code in 200..299 || code == 401
} catch (e: Exception) {
Log.e(TAG, "Server check failed: ${e.message}")
false
}
}
_serverStatus.value = if (isReachable) ServerStatus.Reachable else ServerStatus.Unreachable(null)
}
}
fun syncNow() {
if (_isSyncing.value) return
viewModelScope.launch {
_isSyncing.value = true
try {
emitToast("🔄 Synchronisiere...")
val syncService = WebDavSyncService(getApplication())
if (!syncService.hasUnsyncedChanges()) {
emitToast("✅ Bereits synchronisiert")
return@launch
}
val result = syncService.syncNotes()
if (result.isSuccess) {
emitToast("${result.syncedCount} Notizen synchronisiert")
} else {
emitToast("${result.errorMessage}")
}
} catch (e: Exception) {
emitToast("❌ Fehler: ${e.message}")
} finally {
_isSyncing.value = false
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Sync Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setAutoSync(enabled: Boolean) {
_autoSyncEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_AUTO_SYNC, enabled).apply()
viewModelScope.launch {
if (enabled) {
// v1.5.0 Fix: Trigger battery optimization check and network monitor restart
_events.emit(SettingsEvent.RequestBatteryOptimization)
_events.emit(SettingsEvent.RestartNetworkMonitor)
emitToast("✅ Auto-Sync aktiviert")
} else {
_events.emit(SettingsEvent.RestartNetworkMonitor)
emitToast("Auto-Sync deaktiviert")
}
}
}
fun setSyncInterval(minutes: Long) {
_syncInterval.value = minutes
prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, minutes).apply()
viewModelScope.launch {
val text = when (minutes) {
15L -> "15 Minuten"
60L -> "60 Minuten"
else -> "30 Minuten"
}
emitToast("⏱️ Sync-Intervall: $text")
}
}
// ═══════════════════════════════════════════════════════════════════════
// Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setMarkdownAutoSync(enabled: Boolean) {
if (enabled) {
// v1.5.0 Fix: Perform initial export when enabling (like old SettingsActivity)
viewModelScope.launch {
try {
// Check server configuration first
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
emitToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
// Don't enable - revert state
return@launch
}
// Check if there are notes to export
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(getApplication())
val noteCount = noteStorage.loadAllNotes().size
if (noteCount > 0) {
// Show progress and perform initial export
_markdownExportProgress.value = MarkdownExportProgress(0, noteCount)
val syncService = WebDavSyncService(getApplication())
val exportedCount = withContext(Dispatchers.IO) {
syncService.exportAllNotesToMarkdown(
serverUrl = serverUrl,
username = username,
password = password,
onProgress = { current, total ->
_markdownExportProgress.value = MarkdownExportProgress(current, total)
}
)
}
// Export successful - save settings
_markdownAutoSync.value = true
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply()
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast("$exportedCount Notizen nach Markdown exportiert")
// Clear progress after short delay
kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null
} else {
// No notes - just enable the feature
_markdownAutoSync.value = true
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, true)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, true)
.apply()
emitToast("📝 Markdown Auto-Sync aktiviert")
}
} catch (e: Exception) {
_markdownExportProgress.value = null
emitToast("❌ Export fehlgeschlagen: ${e.message}")
// Don't enable on error
}
}
} else {
// Disable - simple
_markdownAutoSync.value = false
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
.apply()
viewModelScope.launch {
emitToast("📝 Markdown Auto-Sync deaktiviert")
}
}
}
fun performManualMarkdownSync() {
viewModelScope.launch {
try {
emitToast("📝 Markdown-Sync läuft...")
val syncService = WebDavSyncService(getApplication())
val result = syncService.manualMarkdownSync()
emitToast("✅ Export: ${result.exportedCount} • Import: ${result.importedCount}")
} catch (e: Exception) {
emitToast("❌ Fehler: ${e.message}")
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Backup Actions
// ═══════════════════════════════════════════════════════════════════════
fun createBackup(uri: Uri) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.createBackup(uri)
emitToast(if (result.success) "${result.message}" else "${result.error}")
} catch (e: Exception) {
emitToast("❌ Backup fehlgeschlagen: ${e.message}")
} finally {
_isBackupInProgress.value = false
}
}
}
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
val result = backupManager.restoreBackup(uri, mode)
emitToast(if (result.success) "${result.importedNotes} Notizen wiederhergestellt" else "${result.error}")
} catch (e: Exception) {
emitToast("❌ Wiederherstellung fehlgeschlagen: ${e.message}")
} finally {
_isBackupInProgress.value = false
}
}
}
fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch {
_isBackupInProgress.value = true
try {
emitToast("📥 Lade vom Server...")
val syncService = WebDavSyncService(getApplication())
val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode)
}
emitToast(if (result.isSuccess) "${result.restoredCount} Notizen wiederhergestellt" else "${result.errorMessage}")
} catch (e: Exception) {
emitToast("❌ Fehler: ${e.message}")
} finally {
_isBackupInProgress.value = false
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// Debug Settings Actions
// ═══════════════════════════════════════════════════════════════════════
fun setFileLogging(enabled: Boolean) {
_fileLoggingEnabled.value = enabled
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, enabled).apply()
Logger.setFileLoggingEnabled(enabled)
viewModelScope.launch {
emitToast(if (enabled) "📝 Datei-Logging aktiviert" else "📝 Datei-Logging deaktiviert")
}
}
fun clearLogs() {
viewModelScope.launch {
try {
val cleared = Logger.clearLogFile(getApplication())
emitToast(if (cleared) "🗑️ Logs gelöscht" else "📭 Keine Logs zum Löschen")
} catch (e: Exception) {
emitToast("❌ Fehler: ${e.message}")
}
}
}
fun getLogFile() = Logger.getLogFile(getApplication())
// ═══════════════════════════════════════════════════════════════════════
// Helper
// ═══════════════════════════════════════════════════════════════════════
private suspend fun emitToast(message: String) {
_showToast.emit(message)
}
/**
* Server status states
*/
sealed class ServerStatus {
data object Unknown : ServerStatus()
data object NotConfigured : ServerStatus()
data object Checking : ServerStatus()
data object Reachable : ServerStatus()
data class Unreachable(val error: String?) : ServerStatus()
}
/**
* Events for Activity-level actions (dialogs, intents, etc.)
* v1.5.0: Ported from old SettingsActivity
*/
sealed class SettingsEvent {
data object RequestBatteryOptimization : SettingsEvent()
data object RestartNetworkMonitor : SettingsEvent()
}
/**
* Progress state for Markdown export
* v1.5.0: For initial export progress dialog
*/
data class MarkdownExportProgress(
val current: Int,
val total: Int,
val isComplete: Boolean = false
)
}

View File

@@ -0,0 +1,113 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Clickable Settings group card with icon, title, subtitle and optional status
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsCard(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
statusText: String? = null,
statusColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: () -> Unit
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with circle background
Box(
modifier = Modifier
.size(40.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
// Content
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (statusText != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = statusColor
)
}
}
// Arrow
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,152 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Primary filled button for settings actions
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false
) {
Button(
onClick = onClick,
enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text)
}
}
}
/**
* Outlined secondary button for settings actions
*/
@Composable
fun SettingsOutlinedButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false
) {
OutlinedButton(
onClick = onClick,
enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text)
}
}
}
/**
* Danger/destructive button for settings actions
*/
@Composable
fun SettingsDangerButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text(text)
}
}
/**
* Info card with description text
*/
@Composable
fun SettingsInfoCard(
text: String,
modifier: Modifier = Modifier
) {
androidx.compose.material3.Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
)
}
}
/**
* Section header text
*/
@Composable
fun SettingsSectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
/**
* Divider between settings groups
*/
@Composable
fun SettingsDivider(
modifier: Modifier = Modifier
) {
Spacer(modifier = modifier.height(8.dp))
androidx.compose.material3.HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(8.dp))
}

View File

@@ -0,0 +1,94 @@
package dev.dettmer.simplenotes.ui.settings.components
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.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
/**
* Data class for radio option
*/
data class RadioOption<T>(
val value: T,
val title: String,
val subtitle: String? = null
)
/**
* Settings radio group for selecting one option
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun <T> SettingsRadioGroup(
options: List<RadioOption<T>>,
selectedValue: T,
onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
title: String? = null
) {
Column(
modifier = modifier
.fillMaxWidth()
.selectableGroup()
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
options.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = option.value == selectedValue,
onClick = { onValueSelected(option.value) },
role = Role.RadioButton
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = option.value == selectedValue,
onClick = null // handled by selectable
)
Column(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f)
) {
Text(
text = option.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (option.subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = option.subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Reusable Scaffold with back-navigation TopAppBar
* v1.5.0: Jetpack Compose Settings Redesign
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface
)
)
},
containerColor = MaterialTheme.colorScheme.surface
) { paddingValues ->
content(paddingValues)
}
}

View File

@@ -0,0 +1,83 @@
package dev.dettmer.simplenotes.ui.settings.components
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Settings switch item with title, optional subtitle and icon
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsSwitch(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ImageVector? = null,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = if (enabled) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
}
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = if (enabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
}
)
}
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}

View File

@@ -0,0 +1,218 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Policy
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
/**
* About app information screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun AboutScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
val githubRepoUrl = "https://github.com/inventory69/simple-notes-sync"
val githubProfileUrl = "https://github.com/inventory69"
val licenseUrl = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
SettingsScaffold(
title = "Über diese App",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(16.dp))
// App Info Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "📝",
style = MaterialTheme.typography.displayMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Simple Notes Sync",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
SettingsSectionHeader(text = "Links")
// GitHub Repository
AboutLinkItem(
icon = Icons.Default.Code,
title = "GitHub Repository",
subtitle = "Quellcode, Issues & Dokumentation",
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubRepoUrl))
context.startActivity(intent)
}
)
// Developer
AboutLinkItem(
icon = Icons.Default.Person,
title = "Entwickler",
subtitle = "GitHub Profil: @inventory69",
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubProfileUrl))
context.startActivity(intent)
}
)
// License
AboutLinkItem(
icon = Icons.Default.Policy,
title = "Lizenz",
subtitle = "MIT License - Open Source",
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(licenseUrl))
context.startActivity(intent)
}
)
SettingsDivider()
// Data Privacy Info
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "🔒 Datenschutz",
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Diese App sammelt keine Daten. Alle Notizen werden " +
"nur lokal auf deinem Gerät und auf deinem eigenen " +
"WebDAV-Server gespeichert. Keine Telemetrie, keine Werbung.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Clickable link item for About section
*/
@Composable
private fun AboutLinkItem(
icon: ImageVector,
title: String,
subtitle: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,256 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Backup and restore settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun BackupSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
// Restore dialog state
var showRestoreDialog by remember { mutableStateOf(false) }
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
// File picker launchers
val createBackupLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { viewModel.createBackup(it) }
}
val restoreFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
pendingRestoreUri = it
restoreSource = RestoreSource.LocalFile
showRestoreDialog = true
}
}
SettingsScaffold(
title = "Backup & Wiederherstellung",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Info Card
SettingsInfoCard(
text = "📦 Bei jeder Wiederherstellung wird automatisch ein " +
"Sicherheits-Backup erstellt."
)
Spacer(modifier = Modifier.height(16.dp))
// Local Backup Section
SettingsSectionHeader(text = "Lokales Backup")
Spacer(modifier = Modifier.height(8.dp))
SettingsButton(
text = "💾 Backup erstellen",
onClick = {
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date())
val filename = "simplenotes_backup_$timestamp.json"
createBackupLauncher.launch(filename)
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
SettingsOutlinedButton(
text = "📂 Aus Datei wiederherstellen",
onClick = {
restoreFileLauncher.launch(arrayOf("application/json"))
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
SettingsDivider()
// Server Backup Section
SettingsSectionHeader(text = "Server-Backup")
Spacer(modifier = Modifier.height(8.dp))
SettingsOutlinedButton(
text = "☁️ Vom Server wiederherstellen",
onClick = {
restoreSource = RestoreSource.Server
showRestoreDialog = true
},
isLoading = isBackupInProgress,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Restore Mode Dialog
if (showRestoreDialog) {
RestoreModeDialog(
source = restoreSource,
selectedMode = selectedRestoreMode,
onModeSelected = { selectedRestoreMode = it },
onConfirm = {
showRestoreDialog = false
when (restoreSource) {
RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri ->
viewModel.restoreFromFile(uri, selectedRestoreMode)
}
}
RestoreSource.Server -> {
viewModel.restoreFromServer(selectedRestoreMode)
}
}
},
onDismiss = {
showRestoreDialog = false
pendingRestoreUri = null
}
)
}
}
/**
* Restore source enum
*/
private enum class RestoreSource {
LocalFile,
Server
}
/**
* Dialog for selecting restore mode
*/
@Composable
private fun RestoreModeDialog(
source: RestoreSource,
selectedMode: RestoreMode,
onModeSelected: (RestoreMode) -> Unit,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
val sourceText = when (source) {
RestoreSource.LocalFile -> "Lokale Datei"
RestoreSource.Server -> "WebDAV Server"
}
val modeOptions = listOf(
RadioOption(
value = RestoreMode.MERGE,
title = "⚪ Zusammenführen (Standard)",
subtitle = "Neue hinzufügen, Bestehende behalten"
),
RadioOption(
value = RestoreMode.REPLACE,
title = "⚪ Ersetzen",
subtitle = "Alle löschen & Backup importieren"
),
RadioOption(
value = RestoreMode.OVERWRITE_DUPLICATES,
title = "⚪ Duplikate überschreiben",
subtitle = "Backup gewinnt bei Konflikten"
)
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("⚠️ Backup wiederherstellen?") },
text = {
Column {
Text(
text = "Quelle: $sourceText",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Wiederherstellungs-Modus:",
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(8.dp))
SettingsRadioGroup(
options = modeOptions,
selectedValue = selectedMode,
onValueSelected = onModeSelected
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = " Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Wiederherstellen")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}

View File

@@ -0,0 +1,148 @@
package dev.dettmer.simplenotes.ui.settings.screens
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
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.automirrored.filled.Notes
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDangerButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
* Debug and diagnostics settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun DebugSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val context = LocalContext.current
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
var showClearLogsDialog by remember { mutableStateOf(false) }
SettingsScaffold(
title = "Debug & Diagnose",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// File Logging Toggle
SettingsSwitch(
title = "Datei-Logging",
subtitle = "Sync-Logs in Datei speichern",
checked = fileLoggingEnabled,
onCheckedChange = { viewModel.setFileLogging(it) },
icon = Icons.AutoMirrored.Filled.Notes
)
// Privacy Info
SettingsInfoCard(
text = "🔒 Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert " +
"und niemals an externe Server gesendet. Die Logs enthalten " +
"Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen " +
"oder exportieren."
)
SettingsDivider()
SettingsSectionHeader(text = "Log-Aktionen")
Spacer(modifier = Modifier.height(8.dp))
// Export Logs Button
SettingsButton(
text = "📤 Logs exportieren & teilen",
onClick = {
val logFile = viewModel.getLogFile()
if (logFile != null && logFile.exists() && logFile.length() > 0L) {
val logUri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, "SimpleNotes Sync Logs")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, "Logs teilen via..."))
}
},
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Clear Logs Button
SettingsDangerButton(
text = "🗑️ Logs löschen",
onClick = { showClearLogsDialog = true },
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
// Clear Logs Confirmation Dialog
if (showClearLogsDialog) {
AlertDialog(
onDismissRequest = { showClearLogsDialog = false },
title = { Text("Logs löschen?") },
text = {
Text("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
},
confirmButton = {
TextButton(
onClick = {
showClearLogsDialog = false
viewModel.clearLogs()
}
) {
Text("Löschen")
}
},
dismissButton = {
TextButton(onClick = { showClearLogsDialog = false }) {
Text("Abbrechen")
}
}
)
}
}

View File

@@ -0,0 +1,128 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.Description
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/**
* Markdown Desktop integration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun MarkdownSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val exportProgress by viewModel.markdownExportProgress.collectAsState()
// v1.5.0 Fix: Progress Dialog for initial export
exportProgress?.let { progress ->
AlertDialog(
onDismissRequest = { /* Not dismissable */ },
title = { Text("Markdown Auto-Sync") },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = if (progress.isComplete) {
"✅ Export abgeschlossen"
} else {
"Exportiere ${progress.current}/${progress.total} Notizen..."
},
style = MaterialTheme.typography.bodyMedium
)
LinearProgressIndicator(
progress = {
if (progress.total > 0) {
progress.current.toFloat() / progress.total.toFloat()
} else 0f
},
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = { /* No button - auto dismiss */ }
)
}
SettingsScaffold(
title = "Markdown Desktop-Integration",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Info Card
SettingsInfoCard(
text = "📝 Exportiert Notizen zusätzlich als .md-Dateien. Mounte " +
"WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem " +
"Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
)
Spacer(modifier = Modifier.height(8.dp))
// Markdown Auto-Sync Toggle
SettingsSwitch(
title = "Markdown Auto-Sync",
subtitle = "Synchronisiert Notizen automatisch als .md-Dateien (Upload + Download bei jedem Sync)",
checked = markdownAutoSync,
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
icon = Icons.Default.Description
)
// Manual sync button (only visible when auto-sync is off)
if (!markdownAutoSync) {
SettingsDivider()
SettingsInfoCard(
text = "Manueller Sync exportiert alle Notizen als .md-Dateien und " +
"importiert .md-Dateien vom Server. Nützlich für einmalige Synchronisation."
)
Spacer(modifier = Modifier.height(8.dp))
SettingsButton(
text = "📝 Manueller Markdown-Sync",
onClick = { viewModel.performManualMarkdownSync() },
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,241 @@
package dev.dettmer.simplenotes.ui.settings.screens
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.fillMaxSize
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.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Server configuration settings screen
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun ServerSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState()
val isHttps by viewModel.isHttps.collectAsState()
val serverStatus by viewModel.serverStatus.collectAsState()
val isSyncing by viewModel.isSyncing.collectAsState()
var passwordVisible by remember { mutableStateOf(false) }
// Check server status on load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
}
SettingsScaffold(
title = "Server-Einstellungen",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Verbindungstyp
Text(
text = "Verbindungstyp",
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("🏠 Intern (HTTP)") },
modifier = Modifier.weight(1f)
)
FilterChip(
selected = isHttps,
onClick = { viewModel.updateProtocol(true) },
label = { Text("🌐 Extern (HTTPS)") },
modifier = Modifier.weight(1f)
)
}
Text(
text = if (!isHttps) {
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
} else {
"HTTPS für sichere Verbindungen über das Internet"
},
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("Server-Adresse") },
supportingText = { Text("z.B. http://192.168.0.188:8080/notes") },
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("Benutzername") },
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("Passwort") },
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) "Verstecken" else "Anzeigen"
)
}
},
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
Spacer(modifier = Modifier.height(16.dp))
// Server-Status
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Server-Status:", style = MaterialTheme.typography.labelLarge)
Text(
text = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> "✅ Erreichbar"
is SettingsViewModel.ServerStatus.Unreachable -> "❌ Nicht erreichbar"
is SettingsViewModel.ServerStatus.Checking -> "🔍 Prüfe..."
is SettingsViewModel.ServerStatus.NotConfigured -> "⚠️ Nicht konfiguriert"
else -> "❓ Unbekannt"
},
color = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { viewModel.testConnection() },
modifier = Modifier.weight(1f)
) {
Text("Verbindung testen")
}
Button(
onClick = { viewModel.syncNow() },
enabled = !isSyncing,
modifier = Modifier.weight(1f)
) {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Jetzt synchronisieren")
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Sync
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.ui.settings.SettingsRoute
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.SettingsCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
/**
* Main Settings overview screen with clickable group cards
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SettingsMainScreen(
viewModel: SettingsViewModel,
onNavigate: (SettingsRoute) -> Unit,
onBack: () -> Unit
) {
val serverUrl by viewModel.serverUrl.collectAsState()
val serverStatus by viewModel.serverStatus.collectAsState()
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
// Check server status on first load
LaunchedEffect(Unit) {
viewModel.checkServerStatus()
}
SettingsScaffold(
title = "Einstellungen",
onBack = onBack
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(vertical = 8.dp)
) {
// Server-Einstellungen
item {
// v1.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert"
val isConfigured = serverUrl.isNotEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
SettingsCard(
icon = Icons.Default.Cloud,
title = "Server-Einstellungen",
subtitle = if (isConfigured) serverUrl else null,
statusText = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> "✅ Erreichbar"
is SettingsViewModel.ServerStatus.Unreachable -> "❌ Nicht erreichbar"
is SettingsViewModel.ServerStatus.Checking -> "🔍 Prüfe..."
is SettingsViewModel.ServerStatus.NotConfigured -> "⚠️ Nicht konfiguriert"
else -> null
},
statusColor = when (serverStatus) {
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
else -> Color.Gray
},
onClick = { onNavigate(SettingsRoute.Server) }
)
}
// Sync-Einstellungen
item {
val intervalText = when (syncInterval) {
15L -> "15 Min"
60L -> "60 Min"
else -> "30 Min"
}
SettingsCard(
icon = Icons.Default.Sync,
title = "Sync-Einstellungen",
subtitle = if (autoSyncEnabled) "Auto-Sync: An • $intervalText" else "Auto-Sync: Aus",
onClick = { onNavigate(SettingsRoute.Sync) }
)
}
// Markdown-Integration
item {
SettingsCard(
icon = Icons.Default.Description,
title = "Markdown Desktop-Integration",
subtitle = if (markdownAutoSync) "Auto-Sync: An" else "Auto-Sync: Aus",
onClick = { onNavigate(SettingsRoute.Markdown) }
)
}
// Backup & Wiederherstellung
item {
SettingsCard(
icon = Icons.Default.Backup,
title = "Backup & Wiederherstellung",
subtitle = "Lokales oder Server-Backup",
onClick = { onNavigate(SettingsRoute.Backup) }
)
}
// Über diese App
item {
SettingsCard(
icon = Icons.Default.Info,
title = "Über diese App",
subtitle = "Version ${BuildConfig.VERSION_NAME}",
onClick = { onNavigate(SettingsRoute.About) }
)
}
// Debug & Diagnose
item {
SettingsCard(
icon = Icons.Default.BugReport,
title = "Debug & Diagnose",
subtitle = if (fileLoggingEnabled) "Logging: An" else "Logging: Aus",
onClick = { onNavigate(SettingsRoute.Debug) }
)
}
}
}
}

View File

@@ -0,0 +1,112 @@
package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
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)
* v1.5.0: Jetpack Compose Settings Redesign
*/
@Composable
fun SyncSettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit
) {
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState()
SettingsScaffold(
title = "Sync-Einstellungen",
onBack = onBack
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Info
SettingsInfoCard(
text = "🔄 Auto-Sync:\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)"
)
Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Toggle
SettingsSwitch(
title = "Auto-Sync aktiviert",
checked = autoSyncEnabled,
onCheckedChange = { viewModel.setAutoSync(it) },
icon = Icons.Default.Sync
)
SettingsDivider()
// Sync Interval Section
SettingsSectionHeader(text = "Sync-Intervall")
SettingsInfoCard(
text = "Legt fest, wie oft die App im Hintergrund synchronisiert. " +
"Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n" +
"⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die " +
"Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. " +
"Das ist normal und betrifft alle Hintergrund-Apps."
)
Spacer(modifier = Modifier.height(8.dp))
// Interval Radio Group
val intervalOptions = listOf(
RadioOption(
value = 15L,
title = "⚡ Alle 15 Minuten",
subtitle = "Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)"
),
RadioOption(
value = 30L,
title = "✓ Alle 30 Minuten (Empfohlen)",
subtitle = "Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)"
),
RadioOption(
value = 60L,
title = "🔋 Alle 60 Minuten",
subtitle = "Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)"
)
)
SettingsRadioGroup(
options = intervalOptions,
selectedValue = syncInterval,
onValueSelected = { viewModel.setSyncInterval(it) }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}