feat(v1.8.0): IMPL_04 Backup Settings - Improved Progress Display

- Remove in-button spinner from SettingsButton and SettingsOutlinedButton
- Buttons keep text during loading and become disabled (enabled=false)
- Add BackupProgressCard component with LinearProgressIndicator + status text
- Add backupStatusText StateFlow in SettingsViewModel
- Add 3-phase status system: In Progress → Completion → Clear
- Show success completion status for 2 seconds ("Backup created!", etc.)
- Show error status for 3 seconds ("Backup failed", etc.)
- Status auto-clears after delay (no manual intervention needed)
- Update createBackup() with completion delay and status messages
- Update restoreFromFile() with completion delay and status messages
- Update restoreFromServer() with completion delay and status messages
- Remove all redundant toast messages from backup/restore operations
- Add exception logging (Logger.e) to replace swallowed exceptions
- Integrate BackupProgressCard in BackupSettingsScreen (visible during operations)
- Add delayed restore execution (200ms) to ensure dialog closes before progress shows
- Add DIALOG_CLOSE_DELAY_MS constant (200ms)
- Add STATUS_CLEAR_DELAY_SUCCESS_MS constant (2000ms)
- Add STATUS_CLEAR_DELAY_ERROR_MS constant (3000ms)
- Add 6 new backup completion/error strings (EN + DE)
- Import kotlinx.coroutines.delay for status delay functionality
This commit is contained in:
inventory69
2026-02-10 15:24:32 +01:00
parent 4a621b622b
commit 3e946edafb
5 changed files with 182 additions and 46 deletions

View File

@@ -14,6 +14,7 @@ import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -40,6 +41,8 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
companion object { companion object {
private const val TAG = "SettingsViewModel" private const val TAG = "SettingsViewModel"
private const val CONNECTION_TIMEOUT_MS = 3000 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) 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) private val _isBackupInProgress = MutableStateFlow(false)
val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow() val isBackupInProgress: StateFlow<Boolean> = _isBackupInProgress.asStateFlow()
// v1.8.0: Descriptive backup status text
private val _backupStatusText = MutableStateFlow("")
val backupStatusText: StateFlow<String> = _backupStatusText.asStateFlow()
private val _showToast = MutableSharedFlow<String>() private val _showToast = MutableSharedFlow<String>()
val showToast: SharedFlow<String> = _showToast.asSharedFlow() val showToast: SharedFlow<String> = _showToast.asSharedFlow()
@@ -671,18 +678,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun createBackup(uri: Uri, password: String? = null) { fun createBackup(uri: Uri, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_creating)
try { try {
val result = backupManager.createBackup(uri, password) 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 { } 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) { } 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 { } finally {
_isBackupInProgress.value = false _isBackupInProgress.value = false
_backupStatusText.value = ""
} }
} }
} }
@@ -690,18 +706,27 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) { fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring)
try { try {
val result = backupManager.restoreBackup(uri, mode, password) 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 { } 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) { } 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 { } finally {
_isBackupInProgress.value = false _isBackupInProgress.value = false
_backupStatusText.value = ""
} }
} }
} }
@@ -732,22 +757,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
fun restoreFromServer(mode: RestoreMode) { fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
_backupStatusText.value = getString(R.string.backup_progress_restoring_server)
try { try {
emitToast(getString(R.string.restore_progress))
val syncService = WebDavSyncService(getApplication()) val syncService = WebDavSyncService(getApplication())
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.restoreFromServer(mode) 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 { } 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) { } 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 { } finally {
_isBackupInProgress.value = false _isBackupInProgress.value = false
_backupStatusText.value = ""
} }
} }
} }

View File

@@ -1,9 +1,13 @@
package dev.dettmer.simplenotes.ui.settings.components 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -11,12 +15,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* Primary filled button for settings actions * Primary filled button for settings actions
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
* v1.8.0: Button keeps text during loading, just becomes disabled
*/ */
@Composable @Composable
fun SettingsButton( fun SettingsButton(
@@ -31,20 +37,13 @@ fun SettingsButton(
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
if (isLoading) { Text(text)
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text)
}
} }
} }
/** /**
* Outlined secondary button for settings actions * Outlined secondary button for settings actions
* v1.8.0: Button keeps text during loading, just becomes disabled
*/ */
@Composable @Composable
fun SettingsOutlinedButton( fun SettingsOutlinedButton(
@@ -59,15 +58,7 @@ fun SettingsOutlinedButton(
enabled = enabled && !isLoading, enabled = enabled && !isLoading,
modifier = modifier.fillMaxWidth() modifier = modifier.fillMaxWidth()
) { ) {
if (isLoading) { Text(text)
CircularProgressIndicator(
modifier = Modifier.height(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(text)
}
} }
} }
@@ -159,3 +150,48 @@ fun SettingsDivider(
) )
Spacer(modifier = Modifier.height(8.dp)) 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
)
}
}
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -27,6 +28,7 @@ import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.backup.RestoreMode import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog 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.RadioOption
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider 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.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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 * Backup and restore settings screen
@@ -60,6 +66,10 @@ fun BackupSettingsScreen(
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) } var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) } 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 // 🔐 v1.7.0: Encryption state
var encryptBackup by remember { mutableStateOf(false) } var encryptBackup by remember { mutableStateOf(false) }
var showEncryptionPasswordDialog 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( SettingsScaffold(
title = stringResource(R.string.backup_settings_title), title = stringResource(R.string.backup_settings_title),
onBack = onBack onBack = onBack
@@ -108,6 +127,16 @@ fun BackupSettingsScreen(
text = stringResource(R.string.backup_auto_info) 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)) Spacer(modifier = Modifier.height(16.dp))
// Local Backup Section // Local Backup Section
@@ -234,21 +263,29 @@ fun BackupSettingsScreen(
when (restoreSource) { when (restoreSource) {
RestoreSource.LocalFile -> { RestoreSource.LocalFile -> {
pendingRestoreUri?.let { uri -> pendingRestoreUri?.let { uri ->
// 🔐 v1.7.0: Check if backup is encrypted // v1.8.0: Schedule restore with delay for dialog close
viewModel.checkBackupEncryption( pendingRestoreAction = {
uri = uri, // 🔐 v1.7.0: Check if backup is encrypted
onEncrypted = { viewModel.checkBackupEncryption(
showDecryptionPasswordDialog = true uri = uri,
}, onEncrypted = {
onPlaintext = { showDecryptionPasswordDialog = true
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null) },
pendingRestoreUri = null onPlaintext = {
} viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
) pendingRestoreUri = null
}
)
}
triggerRestore++
} }
} }
RestoreSource.Server -> { RestoreSource.Server -> {
viewModel.restoreFromServer(selectedRestoreMode) // v1.8.0: Schedule restore with delay for dialog close
pendingRestoreAction = {
viewModel.restoreFromServer(selectedRestoreMode)
}
triggerRestore++
} }
} }
}, },

View File

@@ -443,6 +443,21 @@
<string name="about_changelog_title">Changelog</string> <string name="about_changelog_title">Changelog</string>
<string name="about_changelog_subtitle">Vollständige Versionshistorie</string> <string name="about_changelog_subtitle">Vollständige Versionshistorie</string>
<!-- v1.8.0: Backup Progress -->
<string name="backup_progress_creating">Backup wird erstellt…</string>
<string name="backup_progress_restoring">Backup wird wiederhergestellt…</string>
<string name="backup_progress_restoring_server">Notizen werden vom Server heruntergeladen…</string>
<!-- v1.8.0: Backup Progress - Completion -->
<string name="backup_progress_complete">Backup erstellt!</string>
<string name="restore_progress_complete">Wiederherstellung abgeschlossen!</string>
<string name="restore_server_progress_complete">Download abgeschlossen!</string>
<!-- v1.8.0: Backup Progress - Error -->
<string name="backup_progress_failed">Backup fehlgeschlagen</string>
<string name="restore_progress_failed">Wiederherstellung fehlgeschlagen</string>
<string name="restore_server_progress_failed">Download fehlgeschlagen</string>
<string name="about_privacy_title">🔒 Datenschutz</string> <string name="about_privacy_title">🔒 Datenschutz</string>
<string name="about_privacy_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.</string> <string name="about_privacy_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.</string>

View File

@@ -443,6 +443,21 @@
<string name="about_changelog_title">Changelog</string> <string name="about_changelog_title">Changelog</string>
<string name="about_changelog_subtitle">Full version history</string> <string name="about_changelog_subtitle">Full version history</string>
<!-- v1.8.0: Backup Progress -->
<string name="backup_progress_creating">Creating backup…</string>
<string name="backup_progress_restoring">Restoring backup…</string>
<string name="backup_progress_restoring_server">Downloading notes from server…</string>
<!-- v1.8.0: Backup Progress - Completion -->
<string name="backup_progress_complete">Backup created!</string>
<string name="restore_progress_complete">Restore complete!</string>
<string name="restore_server_progress_complete">Download complete!</string>
<!-- v1.8.0: Backup Progress - Error -->
<string name="backup_progress_failed">Backup failed</string>
<string name="restore_progress_failed">Restore failed</string>
<string name="restore_server_progress_failed">Download failed</string>
<string name="about_privacy_title">🔒 Privacy</string> <string name="about_privacy_title">🔒 Privacy</string>
<string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string> <string name="about_privacy_text">This app collects no data. All notes are stored only locally on your device and on your own WebDAV server. No telemetry, no ads.</string>