refactor(ui): IMPL_12 - Migrate Toast notifications to Banner system
Changes: - SyncProgress.kt: Add INFO phase to SyncPhase enum - SyncProgress.kt: Update isVisible to always show INFO phase - SyncStateManager.kt: Add showInfo() and showError() methods - SyncProgressBanner.kt: Add INFO icon (Icons.Outlined.Info) + secondaryContainer color - SyncProgressBanner.kt: Add INFO branch in phaseToString() - ComposeMainActivity.kt: Add auto-hide for INFO phase (2.5s delay) - MainViewModel.kt: Replace Toast with Banner in deleteNoteFromServer() - MainViewModel.kt: Replace Toast with Banner in deleteMultipleNotesFromServer() - NoteEditorViewModel.kt: Import SyncStateManager - NoteEditorViewModel.kt: Add Banner feedback (INFO/ERROR) in editor deleteNote() - NoteEditorViewModel.kt: Remove NOTE_SAVED toast (NavigateBack is sufficient) - NoteEditorViewModel.kt: Remove NOTE_DELETED toast (already done) All non-interactive notifications now use the unified Banner system. Server-delete results show as INFO (success) or ERROR (failure) banners. Snackbars with Undo actions and NOTE_IS_EMPTY validation toasts remain unchanged.
This commit is contained in:
@@ -52,9 +52,10 @@ data class SyncProgress(
|
||||
/**
|
||||
* Ob das Banner sichtbar sein soll
|
||||
* Silent syncs zeigen nie ein Banner
|
||||
* 🆕 v1.8.1 (IMPL_12): INFO ist immer sichtbar (nicht vom silent-Flag betroffen)
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = !silent && phase != SyncPhase.IDLE
|
||||
get() = phase == SyncPhase.INFO || (!silent && phase != SyncPhase.IDLE)
|
||||
|
||||
/**
|
||||
* Ob gerade ein aktiver Sync läuft (mit Spinner)
|
||||
@@ -95,5 +96,8 @@ enum class SyncPhase {
|
||||
COMPLETED,
|
||||
|
||||
/** Sync mit Fehler abgebrochen */
|
||||
ERROR
|
||||
ERROR,
|
||||
|
||||
/** 🆕 v1.8.1 (IMPL_12): Kurzfristige Info-Meldung (nicht sync-bezogen) */
|
||||
INFO
|
||||
}
|
||||
|
||||
@@ -245,4 +245,60 @@ object SyncStateManager {
|
||||
fun markGlobalSyncStarted(prefs: android.content.SharedPreferences) {
|
||||
prefs.edit().putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.8.1 (IMPL_12): Info-Meldungen über das Banner-System
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Zeigt eine kurzfristige Info-Meldung im Banner an.
|
||||
* Wird für nicht-sync-bezogene Benachrichtigungen verwendet
|
||||
* (z.B. Server-Delete-Ergebnisse).
|
||||
*
|
||||
* ACHTUNG: Wenn gerade ein Sync läuft (isSyncing), wird die Meldung
|
||||
* ignoriert — der Sync-Progress hat Vorrang.
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (2.5s).
|
||||
*/
|
||||
fun showInfo(message: String) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen — Sync-Fortschritt hat Vorrang
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "ℹ️ Info suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.INFO,
|
||||
resultMessage = message,
|
||||
silent = false // INFO ist nie silent
|
||||
)
|
||||
|
||||
Logger.d(TAG, "ℹ️ Showing info: $message")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt eine Fehlermeldung im Banner an, auch außerhalb eines Syncs.
|
||||
* Für nicht-sync-bezogene Fehler (z.B. Server-Delete fehlgeschlagen).
|
||||
*
|
||||
* Auto-Hide erfolgt über ComposeMainActivity (4s).
|
||||
*/
|
||||
fun showError(message: String?) {
|
||||
synchronized(lock) {
|
||||
// Nicht während aktivem Sync anzeigen
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "❌ Error suppressed during sync: $message")
|
||||
return
|
||||
}
|
||||
|
||||
_syncProgress.value = SyncProgress(
|
||||
phase = SyncPhase.ERROR,
|
||||
resultMessage = message,
|
||||
silent = false
|
||||
)
|
||||
|
||||
Logger.e(TAG, "❌ Showing error: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
@@ -398,7 +399,7 @@ class NoteEditorViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
||||
// 🆕 v1.8.1 (IMPL_12): NOTE_SAVED Toast entfernt — NavigateBack ist ausreichend
|
||||
|
||||
// 🌟 v1.6.0: Trigger onSave Sync
|
||||
triggerOnSaveSync()
|
||||
@@ -438,17 +439,33 @@ class NoteEditorViewModel(
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
webdavService.deleteNoteFromServer(noteId)
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
|
||||
if (success) {
|
||||
Logger.d(TAG, "Note $noteId deleted from server")
|
||||
SyncStateManager.showInfo(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Logger.w(TAG, "Failed to delete note $noteId from server")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_delete_failed
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Error deleting note from server: ${e.message}")
|
||||
SyncStateManager.showError(
|
||||
getApplication<Application>().getString(
|
||||
dev.dettmer.simplenotes.R.string.snackbar_server_error,
|
||||
e.message ?: ""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_DELETED))
|
||||
_events.emit(NoteEditorEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,11 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
kotlinx.coroutines.delay(2000L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
// 🆕 v1.8.1 (IMPL_12): INFO-Meldungen nach 2.5s ausblenden
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.INFO -> {
|
||||
kotlinx.coroutines.delay(2500L)
|
||||
SyncStateManager.reset()
|
||||
}
|
||||
dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
|
||||
kotlinx.coroutines.delay(4000L)
|
||||
SyncStateManager.reset()
|
||||
|
||||
@@ -470,12 +470,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
if (success) {
|
||||
_showToast.emit(getString(R.string.snackbar_deleted_from_server))
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO
|
||||
SyncStateManager.showInfo(getString(R.string.snackbar_deleted_from_server))
|
||||
} else {
|
||||
_showToast.emit(getString(R.string.snackbar_server_delete_failed))
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||
SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
|
||||
} finally {
|
||||
// Remove from pending deletions
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
@@ -507,7 +508,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// Show aggregated toast
|
||||
// 🆕 v1.8.1 (IMPL_12): Toast → Banner INFO/ERROR
|
||||
val message = when {
|
||||
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
|
||||
successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
|
||||
@@ -517,7 +518,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
successCount + failCount
|
||||
)
|
||||
}
|
||||
_showToast.emit(message)
|
||||
if (failCount > 0) {
|
||||
SyncStateManager.showError(message)
|
||||
} else {
|
||||
SyncStateManager.showInfo(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -51,11 +52,13 @@ fun SyncProgressBanner(
|
||||
// Farbe animiert wechseln je nach State
|
||||
val isError = progress.phase == SyncPhase.ERROR
|
||||
val isCompleted = progress.phase == SyncPhase.COMPLETED
|
||||
val isResult = isError || isCompleted
|
||||
val isInfo = progress.phase == SyncPhase.INFO // 🆕 v1.8.1 (IMPL_12)
|
||||
val isResult = isError || isCompleted || isInfo
|
||||
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isError -> MaterialTheme.colorScheme.errorContainer
|
||||
isInfo -> MaterialTheme.colorScheme.secondaryContainer // 🆕 v1.8.1 (IMPL_12)
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
},
|
||||
label = "bannerColor"
|
||||
@@ -64,6 +67,7 @@ fun SyncProgressBanner(
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isError -> MaterialTheme.colorScheme.onErrorContainer
|
||||
isInfo -> MaterialTheme.colorScheme.onSecondaryContainer // 🆕 v1.8.1 (IMPL_12)
|
||||
else -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
},
|
||||
label = "bannerContentColor"
|
||||
@@ -89,7 +93,7 @@ fun SyncProgressBanner(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Icon: Spinner (aktiv), Checkmark (completed), Error (error)
|
||||
// Icon: Spinner (aktiv), Checkmark (completed), Error (error), Info (info)
|
||||
when {
|
||||
isCompleted -> {
|
||||
Icon(
|
||||
@@ -99,6 +103,14 @@ fun SyncProgressBanner(
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
isInfo -> { // 🆕 v1.8.1 (IMPL_12)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = contentColor
|
||||
)
|
||||
}
|
||||
isError -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ErrorOutline,
|
||||
@@ -187,5 +199,6 @@ private fun phaseToString(phase: SyncPhase): String {
|
||||
SyncPhase.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown)
|
||||
SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed)
|
||||
SyncPhase.ERROR -> stringResource(R.string.sync_phase_error)
|
||||
SyncPhase.INFO -> "" // 🆕 v1.8.1 (IMPL_12): INFO nutzt immer resultMessage
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user