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:
inventory69
2026-02-11 11:00:52 +01:00
parent 3e4b1bd07e
commit 27e6b9d4ac
6 changed files with 111 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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