feat(android): #618 Phase 5 — confirm offline writes via shell snackbar
android / Build + lint + test (push) Successful in 3m58s
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:
+63
-43
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+20
@@ -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-
|
||||
|
||||
Reference in New Issue
Block a user