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
|
* 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user