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 index 4f4e61f..670d755 100644 --- 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 @@ -14,6 +14,7 @@ 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.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -40,6 +41,8 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application companion object { private const val TAG = "SettingsViewModel" private const val CONNECTION_TIMEOUT_MS = 3000 + private const val STATUS_CLEAR_DELAY_SUCCESS_MS = 2000L // 2s for successful operations + private const val STATUS_CLEAR_DELAY_ERROR_MS = 3000L // 3s for errors (more important) } private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) @@ -211,6 +214,10 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application private val _isBackupInProgress = MutableStateFlow(false) val isBackupInProgress: StateFlow = _isBackupInProgress.asStateFlow() + // v1.8.0: Descriptive backup status text + private val _backupStatusText = MutableStateFlow("") + val backupStatusText: StateFlow = _backupStatusText.asStateFlow() + private val _showToast = MutableSharedFlow() val showToast: SharedFlow = _showToast.asSharedFlow() @@ -671,18 +678,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun createBackup(uri: Uri, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_creating) try { val result = backupManager.createBackup(uri, password) - val message = if (result.success) { - getString(R.string.toast_backup_success, result.message ?: "") + + // Phase 2: Show completion status + _backupStatusText.value = if (result.success) { + getString(R.string.backup_progress_complete) } else { - getString(R.string.toast_backup_failed, result.error ?: "") + getString(R.string.backup_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_backup_failed, e.message ?: "")) + Logger.e(TAG, "Failed to create backup", e) + _backupStatusText.value = getString(R.string.backup_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } @@ -690,18 +706,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_restoring) try { val result = backupManager.restoreBackup(uri, mode, password) - val message = if (result.success) { - getString(R.string.toast_restore_success, result.importedNotes) + + // Phase 2: Show completion status + _backupStatusText.value = if (result.success) { + getString(R.string.restore_progress_complete) } else { - getString(R.string.toast_restore_failed, result.error ?: "") + getString(R.string.restore_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.success) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_restore_failed, e.message ?: "")) + Logger.e(TAG, "Failed to restore backup from file", e) + _backupStatusText.value = getString(R.string.restore_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } @@ -732,22 +757,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application fun restoreFromServer(mode: RestoreMode) { viewModelScope.launch { _isBackupInProgress.value = true + _backupStatusText.value = getString(R.string.backup_progress_restoring_server) try { - emitToast(getString(R.string.restore_progress)) val syncService = WebDavSyncService(getApplication()) val result = withContext(Dispatchers.IO) { syncService.restoreFromServer(mode) } - val message = if (result.isSuccess) { - getString(R.string.toast_restore_success, result.restoredCount) + + // Phase 2: Show completion status + _backupStatusText.value = if (result.isSuccess) { + getString(R.string.restore_server_progress_complete) } else { - getString(R.string.toast_restore_failed, result.errorMessage ?: "") + getString(R.string.restore_server_progress_failed) } - emitToast(message) + + // Phase 3: Clear after delay + delay(if (result.isSuccess) STATUS_CLEAR_DELAY_SUCCESS_MS else STATUS_CLEAR_DELAY_ERROR_MS) + } catch (e: Exception) { - emitToast(getString(R.string.toast_error, e.message ?: "")) + Logger.e(TAG, "Failed to restore from server", e) + _backupStatusText.value = getString(R.string.restore_server_progress_failed) + delay(STATUS_CLEAR_DELAY_ERROR_MS) } finally { _isBackupInProgress.value = false + _backupStatusText.value = "" } } } 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 index d9c5532..1b06603 100644 --- 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 @@ -1,9 +1,13 @@ 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -11,12 +15,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp /** * Primary filled button for settings actions * v1.5.0: Jetpack Compose Settings Redesign + * v1.8.0: Button keeps text during loading, just becomes disabled */ @Composable fun SettingsButton( @@ -31,20 +37,13 @@ fun SettingsButton( enabled = enabled && !isLoading, modifier = modifier.fillMaxWidth() ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Text(text) - } + Text(text) } } /** * Outlined secondary button for settings actions + * v1.8.0: Button keeps text during loading, just becomes disabled */ @Composable fun SettingsOutlinedButton( @@ -59,15 +58,7 @@ fun SettingsOutlinedButton( enabled = enabled && !isLoading, modifier = modifier.fillMaxWidth() ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.height(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text(text) - } + Text(text) } } @@ -159,3 +150,48 @@ fun SettingsDivider( ) Spacer(modifier = Modifier.height(8.dp)) } + +/** + * v1.8.0: Backup progress indicator shown above buttons + * Replaces the ugly in-button spinner with a clear status display + */ +@Composable +fun BackupProgressCard( + statusText: 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.primaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + } + } +} 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 index a748b6d..b2b0b15 100644 --- 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 @@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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 @@ -27,6 +28,7 @@ import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.ui.settings.SettingsViewModel import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog +import dev.dettmer.simplenotes.ui.settings.components.BackupProgressCard import dev.dettmer.simplenotes.ui.settings.components.RadioOption import dev.dettmer.simplenotes.ui.settings.components.SettingsButton import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider @@ -39,6 +41,10 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.delay + +// v1.8.0: Delay for dialog close animation before starting restore +private const val DIALOG_CLOSE_DELAY_MS = 200L /** * Backup and restore settings screen @@ -60,6 +66,10 @@ fun BackupSettingsScreen( var pendingRestoreUri by remember { mutableStateOf(null) } var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) } + // v1.8.0: Trigger for delayed restore execution (after dialog closes) + var triggerRestore by remember { mutableStateOf(0) } + var pendingRestoreAction by remember { mutableStateOf<(() -> Unit)?>(null) } + // šŸ” v1.7.0: Encryption state var encryptBackup by remember { mutableStateOf(false) } var showEncryptionPasswordDialog by remember { mutableStateOf(false) } @@ -91,6 +101,15 @@ fun BackupSettingsScreen( } } + // v1.8.0: Delayed restore execution after dialog closes + LaunchedEffect(triggerRestore) { + if (triggerRestore > 0) { + delay(DIALOG_CLOSE_DELAY_MS) // Wait for dialog close animation + pendingRestoreAction?.invoke() + pendingRestoreAction = null + } + } + SettingsScaffold( title = stringResource(R.string.backup_settings_title), onBack = onBack @@ -108,6 +127,16 @@ fun BackupSettingsScreen( text = stringResource(R.string.backup_auto_info) ) + // v1.8.0: Progress indicator (visible during backup/restore) + if (isBackupInProgress) { + val backupStatus by viewModel.backupStatusText.collectAsState() + BackupProgressCard( + statusText = backupStatus.ifEmpty { + stringResource(R.string.backup_progress_creating) + } + ) + } + Spacer(modifier = Modifier.height(16.dp)) // Local Backup Section @@ -234,21 +263,29 @@ fun BackupSettingsScreen( when (restoreSource) { RestoreSource.LocalFile -> { pendingRestoreUri?.let { uri -> - // šŸ” v1.7.0: Check if backup is encrypted - viewModel.checkBackupEncryption( - uri = uri, - onEncrypted = { - showDecryptionPasswordDialog = true - }, - onPlaintext = { - viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) - pendingRestoreUri = null - } - ) + // v1.8.0: Schedule restore with delay for dialog close + pendingRestoreAction = { + // šŸ” v1.7.0: Check if backup is encrypted + viewModel.checkBackupEncryption( + uri = uri, + onEncrypted = { + showDecryptionPasswordDialog = true + }, + onPlaintext = { + viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) + pendingRestoreUri = null + } + ) + } + triggerRestore++ } } RestoreSource.Server -> { - viewModel.restoreFromServer(selectedRestoreMode) + // v1.8.0: Schedule restore with delay for dialog close + pendingRestoreAction = { + viewModel.restoreFromServer(selectedRestoreMode) + } + triggerRestore++ } } }, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 205e1bd..8ace8fb 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -443,6 +443,21 @@ Changelog VollstƤndige Versionshistorie + + Backup wird erstellt… + Backup wird wiederhergestellt… + Notizen werden vom Server heruntergeladen… + + + Backup erstellt! + Wiederherstellung abgeschlossen! + Download abgeschlossen! + + + Backup fehlgeschlagen + Wiederherstellung fehlgeschlagen + Download fehlgeschlagen + šŸ”’ Datenschutz 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. diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 146d1ce..3f406bc 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -443,6 +443,21 @@ Changelog Full version history + + Creating backup… + Restoring backup… + Downloading notes from server… + + + Backup created! + Restore complete! + Download complete! + + + Backup failed + Restore failed + Download failed + šŸ”’ Privacy This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.