From 1e17eeda726f37c9b12d148ae65d324e8d230d66 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 12:40:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20#618=20Phase=205=20=E2=80=94?= =?UTF-8?q?=20confirm=20offline=20writes=20via=20shell=20snackbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MutationQueue now emits "Saved — will sync when online" on a SharedFlow whenever a user-driven enqueue lands (like toggle, playlist append, request create/cancel, quarantine flag/unflag). Background enqueues (play-offline events, playback-error reports) do not emit — those fire from non-foreground paths where a snackbar would be either dropped (no shell mounted) or jarring (lock-screen toggle). ShellScaffold subscribes via OfflineWriteHintViewModel and routes the hint through its existing snackbar host. Replaces the prior silent- queue UX where a tap on a like / playlist add looked successful but the user couldn't tell whether the server had been hit or the call was deferred for replay. --- .../minstrel/cache/mutations/MutationQueue.kt | 106 +++++++++++------- .../mutations/OfflineWriteHintViewModel.kt | 20 ++++ .../minstrel/shared/widgets/ShellScaffold.kt | 7 ++ 3 files changed, 90 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 541178cb..ec72f104 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton +private const val QUEUED_MESSAGE = "Saved — will sync when online" + /** * Stable mutation kinds the queue knows how to replay. Strings are * persisted in `cached_mutations.kind` so renaming a variant breaks @@ -71,47 +77,59 @@ class MutationQueue @Inject constructor( private val dao: CachedMutationDao, private val json: Json, ) { + // capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N + // tracks while offline) surfaces as one snackbar rather than queueing + // N. replay=0 because a hint observed at enqueue time isn't useful + // to a screen that mounts later. + private val _userEnqueueHints = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold] + * to surface "Saved — will sync when online" as a snackbar whenever a + * user-driven write hits the offline-fallback path. Background-only + * enqueues (play-events, playback-error reports) do not emit — those + * fire from non-foreground paths where a snackbar would be either + * dropped (no shell mounted) or jarring (lock-screen toggle). + */ + val userEnqueueHints: SharedFlow = _userEnqueueHints.asSharedFlow() + suspend fun enqueueLikeToggle( entityType: String, entityId: String, desiredState: Boolean, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.LIKE_TOGGLE, - payload = json.encodeToString( - LikeTogglePayload.serializer(), - LikeTogglePayload(entityType, entityId, desiredState), - ), + ): Long = insertUserDriven( + MutationKind.LIKE_TOGGLE, + json.encodeToString( + LikeTogglePayload.serializer(), + LikeTogglePayload(entityType, entityId, desiredState), ), ) - suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.REQUEST_CREATE, - payload = json.encodeToString(RequestCreatePayload.serializer(), payload), - ), + suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven( + MutationKind.REQUEST_CREATE, + json.encodeToString(RequestCreatePayload.serializer(), payload), ) - suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.QUARANTINE_UNFLAG, - payload = json.encodeToString( - QuarantineUnflagPayload.serializer(), - QuarantineUnflagPayload(trackId), - ), + suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven( + MutationKind.QUARANTINE_UNFLAG, + json.encodeToString( + QuarantineUnflagPayload.serializer(), + QuarantineUnflagPayload(trackId), ), ) suspend fun enqueuePlaylistAppend( playlistId: String, trackIds: List, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.PLAYLIST_APPEND, - payload = json.encodeToString( - PlaylistAppendPayload.serializer(), - PlaylistAppendPayload(playlistId, trackIds), - ), + ): Long = insertUserDriven( + MutationKind.PLAYLIST_APPEND, + json.encodeToString( + PlaylistAppendPayload.serializer(), + PlaylistAppendPayload(playlistId, trackIds), ), ) @@ -119,13 +137,19 @@ class MutationQueue @Inject constructor( trackId: String, reason: String, notes: String, - ): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.QUARANTINE_FLAG, - payload = json.encodeToString( - QuarantineFlagPayload.serializer(), - QuarantineFlagPayload(trackId, reason, notes), - ), + ): Long = insertUserDriven( + MutationKind.QUARANTINE_FLAG, + json.encodeToString( + QuarantineFlagPayload.serializer(), + QuarantineFlagPayload(trackId, reason, notes), + ), + ) + + suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven( + MutationKind.REQUEST_CANCEL, + json.encodeToString( + RequestCancelPayload.serializer(), + RequestCancelPayload(requestId), ), ) @@ -136,22 +160,18 @@ class MutationQueue @Inject constructor( ), ) - suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert( - CachedMutationEntity( - kind = MutationKind.REQUEST_CANCEL, - payload = json.encodeToString( - RequestCancelPayload.serializer(), - RequestCancelPayload(requestId), - ), - ), - ) - suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert( CachedMutationEntity( kind = MutationKind.PLAYBACK_ERROR_REPORT, payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload), ), ) + + private suspend fun insertUserDriven(kind: String, payload: String): Long { + val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload)) + _userEnqueueHints.tryEmit(QUEUED_MESSAGE) + return id + } } /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt new file mode 100644 index 00000000..b1632ef3 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/OfflineWriteHintViewModel.kt @@ -0,0 +1,20 @@ +package com.fabledsword.minstrel.cache.mutations + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to + * ShellScaffold. The queue itself is an app-scoped singleton; this VM + * just bridges its SharedFlow into a `hiltViewModel()`-resolvable + * surface so ShellScaffold can collect it without an EntryPoint + * accessor. Mirrors PlaybackErrorViewModel. + */ +@HiltViewModel +class OfflineWriteHintViewModel @Inject constructor( + mutationQueue: MutationQueue, +) : ViewModel() { + val messages: Flow = mutationQueue.userEnqueueHints +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt index aa711e78..d5e3310d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/ShellScaffold.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel +import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel @@ -48,6 +49,7 @@ fun ShellScaffold( modifier: Modifier = Modifier, trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), + offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(), content: @Composable () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -61,6 +63,11 @@ fun ShellScaffold( snackbarHostState.showSnackbar(msg) } } + LaunchedEffect(Unit) { + offlineWriteHintViewModel.messages.collect { msg -> + snackbarHostState.showSnackbar(msg) + } + } // Consume the status-bar inset once here so the banner stack sits // below the status bar (mirrors Flutter's SafeArea(bottom:false)). // statusBarsPadding consumes the inset for descendants, so the in-