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 * Ob das Banner sichtbar sein soll
* Silent syncs zeigen nie ein Banner * Silent syncs zeigen nie ein Banner
* 🆕 v1.8.1 (IMPL_12): INFO ist immer sichtbar (nicht vom silent-Flag betroffen)
*/ */
val isVisible: Boolean 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) * Ob gerade ein aktiver Sync läuft (mit Spinner)
@@ -95,5 +96,8 @@ enum class SyncPhase {
COMPLETED, COMPLETED,
/** Sync mit Fehler abgebrochen */ /** 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) { fun markGlobalSyncStarted(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_GLOBAL_SYNC_TIME, System.currentTimeMillis()).apply() 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.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants 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 // 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync() triggerOnSaveSync()
@@ -438,17 +439,33 @@ class NoteEditorViewModel(
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
webdavService.deleteNoteFromServer(noteId) webdavService.deleteNoteFromServer(noteId)
} }
// 🆕 v1.8.1 (IMPL_12): Banner-Feedback statt stiller Log-Einträge
if (success) { if (success) {
Logger.d(TAG, "Note $noteId deleted from server") Logger.d(TAG, "Note $noteId deleted from server")
SyncStateManager.showInfo(
getApplication<Application>().getString(
dev.dettmer.simplenotes.R.string.snackbar_deleted_from_server
)
)
} else { } else {
Logger.w(TAG, "Failed to delete note $noteId from server") 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) { } catch (e: Exception) {
Logger.e(TAG, "Error deleting note from server: ${e.message}") 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) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }

View File

@@ -236,6 +236,11 @@ class ComposeMainActivity : ComponentActivity() {
kotlinx.coroutines.delay(2000L) kotlinx.coroutines.delay(2000L)
SyncStateManager.reset() 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 -> { dev.dettmer.simplenotes.sync.SyncPhase.ERROR -> {
kotlinx.coroutines.delay(4000L) kotlinx.coroutines.delay(4000L)
SyncStateManager.reset() SyncStateManager.reset()

View File

@@ -470,12 +470,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (success) { 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 { } else {
_showToast.emit(getString(R.string.snackbar_server_delete_failed)) SyncStateManager.showError(getString(R.string.snackbar_server_delete_failed))
} }
} catch (e: Exception) { } catch (e: Exception) {
_showToast.emit(getString(R.string.snackbar_server_error, e.message ?: "")) SyncStateManager.showError(getString(R.string.snackbar_server_error, e.message ?: ""))
} finally { } finally {
// Remove from pending deletions // Remove from pending deletions
_pendingDeletions.value = _pendingDeletions.value - noteId _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 { val message = when {
failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount) failCount == 0 -> getString(R.string.snackbar_notes_deleted_from_server, successCount)
successCount == 0 -> getString(R.string.snackbar_server_delete_failed) successCount == 0 -> getString(R.string.snackbar_server_delete_failed)
@@ -517,7 +518,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
successCount + failCount 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.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -51,11 +52,13 @@ fun SyncProgressBanner(
// Farbe animiert wechseln je nach State // Farbe animiert wechseln je nach State
val isError = progress.phase == SyncPhase.ERROR val isError = progress.phase == SyncPhase.ERROR
val isCompleted = progress.phase == SyncPhase.COMPLETED 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( val backgroundColor by animateColorAsState(
targetValue = when { targetValue = when {
isError -> MaterialTheme.colorScheme.errorContainer isError -> MaterialTheme.colorScheme.errorContainer
isInfo -> MaterialTheme.colorScheme.secondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.primaryContainer else -> MaterialTheme.colorScheme.primaryContainer
}, },
label = "bannerColor" label = "bannerColor"
@@ -64,6 +67,7 @@ fun SyncProgressBanner(
val contentColor by animateColorAsState( val contentColor by animateColorAsState(
targetValue = when { targetValue = when {
isError -> MaterialTheme.colorScheme.onErrorContainer isError -> MaterialTheme.colorScheme.onErrorContainer
isInfo -> MaterialTheme.colorScheme.onSecondaryContainer // 🆕 v1.8.1 (IMPL_12)
else -> MaterialTheme.colorScheme.onPrimaryContainer else -> MaterialTheme.colorScheme.onPrimaryContainer
}, },
label = "bannerContentColor" label = "bannerContentColor"
@@ -89,7 +93,7 @@ fun SyncProgressBanner(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
// Icon: Spinner (aktiv), Checkmark (completed), Error (error) // Icon: Spinner (aktiv), Checkmark (completed), Error (error), Info (info)
when { when {
isCompleted -> { isCompleted -> {
Icon( Icon(
@@ -99,6 +103,14 @@ fun SyncProgressBanner(
tint = contentColor tint = contentColor
) )
} }
isInfo -> { // 🆕 v1.8.1 (IMPL_12)
Icon(
imageVector = Icons.Outlined.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = contentColor
)
}
isError -> { isError -> {
Icon( Icon(
imageVector = Icons.Filled.ErrorOutline, 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.IMPORTING_MARKDOWN -> stringResource(R.string.sync_phase_importing_markdown)
SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed) SyncPhase.COMPLETED -> stringResource(R.string.sync_phase_completed)
SyncPhase.ERROR -> stringResource(R.string.sync_phase_error) SyncPhase.ERROR -> stringResource(R.string.sync_phase_error)
SyncPhase.INFO -> "" // 🆕 v1.8.1 (IMPL_12): INFO nutzt immer resultMessage
} }
} }