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.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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.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-
|
||||||
|
|||||||
Reference in New Issue
Block a user