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.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<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(
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<String>,
): 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
}
}
/**
@@ -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.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-