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:
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
||||
// alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.detekt)
|
||||
@@ -20,8 +21,8 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 12 // 🔧 v1.4.1: Bugfixes (Root-Delete, Checklist Compat)
|
||||
versionName = "1.4.1" // 🔧 v1.4.1: Root-Folder Delete Fix, Checklisten-Sync Abwärtskompatibilität
|
||||
versionCode = 13 // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
||||
versionName = "1.5.0" // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -96,6 +97,7 @@ android {
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true // Enable BuildConfig generation
|
||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -135,6 +137,20 @@ dependencies {
|
||||
// SwipeRefreshLayout für Pull-to-Refresh
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Testing (bleiben so)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false // v1.5.0: Jetpack Compose
|
||||
alias(libs.plugins.ktlint) apply false
|
||||
alias(libs.plugins.detekt) apply false
|
||||
}
|
||||
@@ -11,6 +11,11 @@ activity = "1.8.0"
|
||||
constraintlayout = "2.1.4"
|
||||
ktlint = "12.1.0"
|
||||
detekt = "1.23.4"
|
||||
# Jetpack Compose v1.5.0
|
||||
composeBom = "2024.02.00"
|
||||
navigationCompose = "2.7.6"
|
||||
lifecycleRuntimeCompose = "2.7.0"
|
||||
activityCompose = "1.8.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -21,10 +26,22 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
# Jetpack Compose v1.5.0
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user