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.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<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>()
val showToast: SharedFlow<String> = _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 = ""
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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<Uri?>(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++
}
}
},

View File

@@ -443,6 +443,21 @@
<string name="about_changelog_title">Changelog</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_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_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_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>