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:
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user