feat(android): Hidden tab cache-first via Room + Flow (SWR) — responsiveness
Closes #37. The Hidden tab was fetch-on-visit (spinner every open); now it paints instantly from the cached_quarantine_mine Room table and SWR-refreshes underneath. Mirrors Flutter's MyQuarantineController (drift watch + transaction full-replace + optimistic flag/unflag). The Room table + DAO already existed (CachedQuarantineEntity / CachedQuarantineDao with observeAll + insert/delete) — they were just never wired up. This commit connects them: * DatabaseModule: provideCachedQuarantineDao (was missing from the graph — same gap as AudioCacheIndexDao). * CachedQuarantineDao: atomic replaceAll(rows) (@Transaction clear+upsertAll, no flicker). * QuarantineRepository: observeMine() Flow + refresh() full-replace; flag(track) / unflag(id) now mutate the cache optimistically (Flow re-emits instantly) then call the server, enqueueing on failure (intent persists, no rollback — matches Flutter). flag() takes a TrackRef to build the optimistic row; TrackActionsViewModel updated. listMine() kept for the TrackActions hidden-check. * HiddenTabViewModel: uiState = combine(observeMine, refreshError), cache-first; SWR on init + on quarantine.* SSE; unflag via repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
|
||||
@@ -71,6 +72,11 @@ object DatabaseModule {
|
||||
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
|
||||
db.cachedHistorySnapshotDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCachedQuarantineDao(db: AppDatabase): CachedQuarantineDao =
|
||||
db.cachedQuarantineDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
|
||||
|
||||
+16
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -33,4 +34,19 @@ interface CachedQuarantineDao {
|
||||
|
||||
@Query("DELETE FROM cached_quarantine_mine WHERE trackId IN (:trackIds)")
|
||||
suspend fun deleteByTrackIds(trackIds: List<String>)
|
||||
|
||||
@Query("DELETE FROM cached_quarantine_mine")
|
||||
suspend fun clear()
|
||||
|
||||
/**
|
||||
* Atomic full-replace from the server snapshot — one transaction so
|
||||
* the `observeAll()` watcher sees a single merged emission, not a
|
||||
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
|
||||
* `_refreshFromServer` transaction.
|
||||
*/
|
||||
@Transaction
|
||||
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
|
||||
clear()
|
||||
upsertAll(rows)
|
||||
}
|
||||
}
|
||||
|
||||
+94
-30
@@ -2,14 +2,22 @@ package com.fabledsword.minstrel.quarantine.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedQuarantineDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedQuarantineEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.models.QuarantineRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val MS_PER_SECOND = 1000
|
||||
|
||||
/**
|
||||
* Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's
|
||||
* RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the
|
||||
@@ -25,54 +33,79 @@ enum class UnflagOutcome { ACCEPTED, QUEUED }
|
||||
enum class FlagOutcome { ACCEPTED, QUEUED }
|
||||
|
||||
/**
|
||||
* Read-through accessor for the caller's quarantine list + offline-
|
||||
* first flag / unflag writes. No Room caching for v1 (same shape as
|
||||
* Requests/History — offline-snapshot mirror lands with Phase 13).
|
||||
* Cache-first accessor for the caller's quarantine list + offline-
|
||||
* first flag / unflag writes. Mirrors Flutter's `MyQuarantineController`
|
||||
* (drift `cached_quarantine_mine` + SWR + optimistic mutations) using
|
||||
* the native Room + Flow idiom:
|
||||
*
|
||||
* - [observeMine] emits the cached rows, so the Hidden tab paints
|
||||
* from disk instantly and survives connectivity loss.
|
||||
* - [refresh] full-replaces the table from the server snapshot in a
|
||||
* single transaction (no delete-then-insert flicker).
|
||||
* - [flag] / [unflag] mutate the cache optimistically (the Flow
|
||||
* re-emits immediately), call the server, and enqueue on failure
|
||||
* so the intent persists offline. The optimistic row is NOT rolled
|
||||
* back on failure — same as Flutter.
|
||||
*/
|
||||
@Singleton
|
||||
class QuarantineRepository @Inject constructor(
|
||||
private val dao: CachedQuarantineDao,
|
||||
private val mutationQueue: MutationQueue,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: QuarantineApi = retrofit.create()
|
||||
|
||||
/** Cache-first stream of the caller's hidden tracks (newest first). */
|
||||
fun observeMine(): Flow<List<QuarantineRef>> =
|
||||
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
|
||||
/** Pull the server snapshot and atomically replace the cached table. */
|
||||
suspend fun refresh() {
|
||||
val fresh = api.listMine().map { it.toEntity() }
|
||||
dao.replaceAll(fresh)
|
||||
}
|
||||
|
||||
/** Server snapshot without touching the cache — used by the TrackActions hidden check. */
|
||||
suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Flag a track for quarantine with [reason] (one of bad_rip,
|
||||
* wrong_file, wrong_tags, duplicate, other) and an optional
|
||||
* [notes] string visible to admins. On transport failure the
|
||||
* call is enqueued via MutationQueue for later replay; server-
|
||||
* side re-flagging the same (user, track) pair is idempotent.
|
||||
* Flag [track] for quarantine with [reason] (bad_rip / wrong_file /
|
||||
* wrong_tags / duplicate / other) and optional admin-visible
|
||||
* [notes]. Optimistically inserts the cache row (the Hidden tab +
|
||||
* isHidden checks update instantly), then calls the server;
|
||||
* transport failure enqueues for replay (server re-flag is
|
||||
* idempotent).
|
||||
*/
|
||||
suspend fun flag(trackId: String, reason: String, notes: String): FlagOutcome = try {
|
||||
api.flag(FlagRequest(trackId = trackId, reason = reason, notes = notes))
|
||||
FlagOutcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: queue the flag for later replay.
|
||||
mutationQueue.enqueueQuarantineFlag(trackId, reason, notes)
|
||||
FlagOutcome.QUEUED
|
||||
suspend fun flag(track: TrackRef, reason: String, notes: String): FlagOutcome {
|
||||
dao.upsert(track.toQuarantineEntity(reason, notes))
|
||||
return try {
|
||||
api.flag(FlagRequest(trackId = track.id, reason = reason, notes = notes))
|
||||
FlagOutcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
mutationQueue.enqueueQuarantineFlag(track.id, reason, notes)
|
||||
FlagOutcome.QUEUED
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unflag(trackId: String): UnflagOutcome = try {
|
||||
api.unflag(trackId)
|
||||
UnflagOutcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: queue the unhide for later replay; the
|
||||
// UI has already removed the row optimistically. Same rationale
|
||||
// as LikesRepository.toggleLike.
|
||||
mutationQueue.enqueueQuarantineUnflag(trackId)
|
||||
UnflagOutcome.QUEUED
|
||||
suspend fun unflag(trackId: String): UnflagOutcome {
|
||||
dao.deleteByTrackId(trackId)
|
||||
return try {
|
||||
api.unflag(trackId)
|
||||
UnflagOutcome.ACCEPTED
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
mutationQueue.enqueueQuarantineUnflag(trackId)
|
||||
UnflagOutcome.QUEUED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mappers (internal — wire stays out of UI) ──
|
||||
// ── Mappers (internal — wire/entity stay out of UI) ──
|
||||
|
||||
private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
|
||||
private fun QuarantineMineWire.toEntity(): CachedQuarantineEntity = CachedQuarantineEntity(
|
||||
trackId = trackId,
|
||||
reason = reason,
|
||||
notes = notes,
|
||||
@@ -85,3 +118,34 @@ private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
|
||||
artistId = artistId,
|
||||
artistName = artistName,
|
||||
)
|
||||
|
||||
private fun CachedQuarantineEntity.toDomain(): QuarantineRef = QuarantineRef(
|
||||
trackId = trackId,
|
||||
reason = reason,
|
||||
notes = notes,
|
||||
createdAt = createdAt,
|
||||
trackTitle = trackTitle,
|
||||
trackDurationMs = trackDurationMs,
|
||||
albumId = albumId,
|
||||
albumTitle = albumTitle,
|
||||
albumCoverArtPath = albumCoverArtPath,
|
||||
artistId = artistId,
|
||||
artistName = artistName,
|
||||
)
|
||||
|
||||
private fun QuarantineMineWire.toDomain(): QuarantineRef = toEntity().toDomain()
|
||||
|
||||
private fun TrackRef.toQuarantineEntity(reason: String, notes: String): CachedQuarantineEntity =
|
||||
CachedQuarantineEntity(
|
||||
trackId = id,
|
||||
reason = reason,
|
||||
notes = notes.ifEmpty { null },
|
||||
createdAt = Clock.System.now().toString(),
|
||||
trackTitle = title,
|
||||
trackDurationMs = durationSec * MS_PER_SECOND,
|
||||
albumId = albumId,
|
||||
albumTitle = albumTitle,
|
||||
albumCoverArtPath = null,
|
||||
artistId = artistId,
|
||||
artistName = artistName,
|
||||
)
|
||||
|
||||
+36
-33
@@ -9,12 +9,16 @@ import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface HiddenTabUiState {
|
||||
data object Loading : HiddenTabUiState
|
||||
data object Empty : HiddenTabUiState
|
||||
@@ -28,17 +32,36 @@ class HiddenTabViewModel @Inject constructor(
|
||||
private val eventsStream: EventsStream,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<HiddenTabUiState>(HiddenTabUiState.Loading)
|
||||
val uiState: StateFlow<HiddenTabUiState> = internal.asStateFlow()
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
/**
|
||||
* Cache-first: emits the cached `cached_quarantine_mine` rows
|
||||
* instantly (paints from disk on cold open), SWR-refreshes
|
||||
* underneath, and shows Error only when there's no cache AND the
|
||||
* refresh failed. Optimistic flag/unflag write the cache, so the
|
||||
* list updates without waiting on the server.
|
||||
*/
|
||||
val uiState: StateFlow<HiddenTabUiState> =
|
||||
combine(repository.observeMine(), refreshError) { rows, err ->
|
||||
when {
|
||||
rows.isNotEmpty() -> HiddenTabUiState.Success(rows)
|
||||
err != null -> HiddenTabUiState.Error(err)
|
||||
else -> HiddenTabUiState.Empty
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = HiddenTabUiState.Loading,
|
||||
)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Live cross-device updates: any quarantine.* event (hide /
|
||||
// unhide / resolve / delete from another device) re-fetches the
|
||||
// list while the tab is open. Mirrors Flutter's LiveEventsDispatcher
|
||||
// invalidating myQuarantineProvider; here the screen-scoped VM
|
||||
// subscribes directly, per the Android dispatcher's documented
|
||||
// pattern for state with no shared repository-refresh.
|
||||
// snapshot while the tab is open. Mirrors Flutter's
|
||||
// LiveEventsDispatcher invalidating myQuarantineProvider; here
|
||||
// the screen-scoped VM subscribes directly, per the Android
|
||||
// dispatcher's documented pattern for screen-scoped state.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind.startsWith("quarantine.") }
|
||||
@@ -47,37 +70,17 @@ class HiddenTabViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
internal.value = HiddenTabUiState.Loading
|
||||
try {
|
||||
val rows = repository.listMine()
|
||||
internal.value = if (rows.isEmpty()) {
|
||||
HiddenTabUiState.Empty
|
||||
} else {
|
||||
HiddenTabUiState.Success(rows)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
internal.value = HiddenTabUiState.Error(ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
refreshError.value = null
|
||||
runCatching { repository.refresh() }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic unhide: drop the row from local state immediately,
|
||||
* call the server, refetch on success or rollback-via-refetch on
|
||||
* failure. The repository handles the offline-queue fallback so
|
||||
* the user's intent persists across connectivity loss.
|
||||
* Optimistic unhide — the repository drops the cache row first (the
|
||||
* observeMine Flow re-emits so the list updates instantly), then
|
||||
* calls the server, enqueueing on failure so the intent persists.
|
||||
*/
|
||||
fun unflag(trackId: String) {
|
||||
val before = internal.value
|
||||
if (before is HiddenTabUiState.Success) {
|
||||
val filtered = before.rows.filterNot { it.trackId == trackId }
|
||||
internal.value = if (filtered.isEmpty()) {
|
||||
HiddenTabUiState.Empty
|
||||
} else {
|
||||
HiddenTabUiState.Success(filtered)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.unflag(trackId) }
|
||||
}
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ class TrackActionsViewModel @Inject constructor(
|
||||
|
||||
fun flag(track: TrackRef, reason: String, notes: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { quarantine.flag(track.id, reason, notes) }
|
||||
runCatching { quarantine.flag(track, reason, notes) }
|
||||
.onSuccess { refreshHidden() }
|
||||
.onFailure { messages.tryEmit("Couldn't hide: ${ErrorCopy.fromThrowable(it)}") }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user