diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 495c3ea..9e188b2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index be931a7..0231cc6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,11 +43,17 @@ android:windowSoftInputMode="adjustResize" android:parentActivityName=".MainActivity" /> - + + + + + 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 + ) +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt new file mode 100644 index 0000000..2b8f5d9 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsNavigation.kt @@ -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() } + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt new file mode 100644 index 0000000..3ce1c25 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsRoute.kt @@ -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") +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..533240c --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt @@ -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 = _serverUrl.asStateFlow() + + private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "") + val username: StateFlow = _username.asStateFlow() + + private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "") + val password: StateFlow = _password.asStateFlow() + + // v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty) + private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://")) + val isHttps: StateFlow = _isHttps.asStateFlow() + + private val _serverStatus = MutableStateFlow(ServerStatus.Unknown) + val serverStatus: StateFlow = _serverStatus.asStateFlow() + + // ═══════════════════════════════════════════════════════════════════════ + // Events (for Activity-level actions like dialogs, intents) + // ═══════════════════════════════════════════════════════════════════════ + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + // ═══════════════════════════════════════════════════════════════════════ + // Markdown Export Progress State + // ═══════════════════════════════════════════════════════════════════════ + + private val _markdownExportProgress = MutableStateFlow(null) + val markdownExportProgress: StateFlow = _markdownExportProgress.asStateFlow() + + // ═══════════════════════════════════════════════════════════════════════ + // Sync Settings State + // ═══════════════════════════════════════════════════════════════════════ + + private val _autoSyncEnabled = MutableStateFlow(prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) + val autoSyncEnabled: StateFlow = _autoSyncEnabled.asStateFlow() + + private val _syncInterval = MutableStateFlow( + prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) + ) + val syncInterval: StateFlow = _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 = _markdownAutoSync.asStateFlow() + + // ═══════════════════════════════════════════════════════════════════════ + // Debug Settings State + // ═══════════════════════════════════════════════════════════════════════ + + private val _fileLoggingEnabled = MutableStateFlow( + prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false) + ) + val fileLoggingEnabled: StateFlow = _fileLoggingEnabled.asStateFlow() + + // ═══════════════════════════════════════════════════════════════════════ + // UI State + // ═══════════════════════════════════════════════════════════════════════ + + private val _isSyncing = MutableStateFlow(false) + val isSyncing: StateFlow = _isSyncing.asStateFlow() + + private val _isBackupInProgress = MutableStateFlow(false) + val isBackupInProgress: StateFlow = _isBackupInProgress.asStateFlow() + + private val _showToast = MutableSharedFlow() + val showToast: SharedFlow = _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 + ) +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsCard.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsCard.kt new file mode 100644 index 0000000..7146bb9 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsCard.kt @@ -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 + ) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt new file mode 100644 index 0000000..30f83ff --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsComponents.kt @@ -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)) +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsRadioGroup.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsRadioGroup.kt new file mode 100644 index 0000000..6fd267c --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsRadioGroup.kt @@ -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( + 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 SettingsRadioGroup( + options: List>, + 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 + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsScaffold.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsScaffold.kt new file mode 100644 index 0000000..a08b383 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsScaffold.kt @@ -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) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt new file mode 100644 index 0000000..63cf998 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/components/SettingsSwitch.kt @@ -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 + ) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt new file mode 100644 index 0000000..c506c72 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/AboutScreen.kt @@ -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 + ) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt new file mode 100644 index 0000000..e3ca5c2 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/BackupSettingsScreen.kt @@ -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.LocalFile) } + var pendingRestoreUri by remember { mutableStateOf(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") + } + } + ) +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt new file mode 100644 index 0000000..c68593c --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/DebugSettingsScreen.kt @@ -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") + } + } + ) + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt new file mode 100644 index 0000000..0b245af --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/MarkdownSettingsScreen.kt @@ -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)) + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt new file mode 100644 index 0000000..6927805 --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/ServerSettingsScreen.kt @@ -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") + } + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt new file mode 100644 index 0000000..25c256f --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SettingsMainScreen.kt @@ -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) } + ) + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt new file mode 100644 index 0000000..7452bae --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/screens/SyncSettingsScreen.kt @@ -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)) + } + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 2aaf92c..4b29661 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 59f739b..ea93011 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" }