feat(android): #618 Phase 5 — confirm offline writes via shell snackbar
android / Build + lint + test (push) Successful in 3m58s

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.
This commit is contained in:
2026-06-04 12:40:59 -04:00
parent 1daea79f64
commit 1e17eeda72
3 changed files with 90 additions and 43 deletions
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity 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.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks * persisted in `cached_mutations.kind` so renaming a variant breaks
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
private val dao: CachedMutationDao, private val dao: CachedMutationDao,
private val json: Json, 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<String>(
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<String> = _userEnqueueHints.asSharedFlow()
suspend fun enqueueLikeToggle( suspend fun enqueueLikeToggle(
entityType: String, entityType: String,
entityId: String, entityId: String,
desiredState: Boolean, desiredState: Boolean,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.LIKE_TOGGLE,
kind = MutationKind.LIKE_TOGGLE, json.encodeToString(
payload = json.encodeToString( LikeTogglePayload.serializer(),
LikeTogglePayload.serializer(), LikeTogglePayload(entityType, entityId, desiredState),
LikeTogglePayload(entityType, entityId, desiredState),
),
), ),
) )
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert( suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
CachedMutationEntity( MutationKind.REQUEST_CREATE,
kind = MutationKind.REQUEST_CREATE, json.encodeToString(RequestCreatePayload.serializer(), payload),
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
),
) )
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert( suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_UNFLAG,
kind = MutationKind.QUARANTINE_UNFLAG, json.encodeToString(
payload = json.encodeToString( QuarantineUnflagPayload.serializer(),
QuarantineUnflagPayload.serializer(), QuarantineUnflagPayload(trackId),
QuarantineUnflagPayload(trackId),
),
), ),
) )
suspend fun enqueuePlaylistAppend( suspend fun enqueuePlaylistAppend(
playlistId: String, playlistId: String,
trackIds: List<String>, trackIds: List<String>,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.PLAYLIST_APPEND,
kind = MutationKind.PLAYLIST_APPEND, json.encodeToString(
payload = json.encodeToString( PlaylistAppendPayload.serializer(),
PlaylistAppendPayload.serializer(), PlaylistAppendPayload(playlistId, trackIds),
PlaylistAppendPayload(playlistId, trackIds),
),
), ),
) )
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
trackId: String, trackId: String,
reason: String, reason: String,
notes: String, notes: String,
): Long = dao.insert( ): Long = insertUserDriven(
CachedMutationEntity( MutationKind.QUARANTINE_FLAG,
kind = MutationKind.QUARANTINE_FLAG, json.encodeToString(
payload = json.encodeToString( QuarantineFlagPayload.serializer(),
QuarantineFlagPayload.serializer(), QuarantineFlagPayload(trackId, reason, notes),
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( suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
CachedMutationEntity( CachedMutationEntity(
kind = MutationKind.PLAYBACK_ERROR_REPORT, kind = MutationKind.PLAYBACK_ERROR_REPORT,
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload), 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
}
} }
/** /**
@@ -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<String> = mutationQueue.userEnqueueHints
}
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
@@ -48,6 +49,7 @@ fun ShellScaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(), trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(), playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -61,6 +63,11 @@ fun ShellScaffold(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
offlineWriteHintViewModel.messages.collect { msg ->
snackbarHostState.showSnackbar(msg)
}
}
// Consume the status-bar inset once here so the banner stack sits // Consume the status-bar inset once here so the banner stack sits
// below the status bar (mirrors Flutter's SafeArea(bottom:false)). // below the status bar (mirrors Flutter's SafeArea(bottom:false)).
// statusBarsPadding consumes the inset for descendants, so the in- // statusBarsPadding consumes the inset for descendants, so the in-