feat(android): History tab cache-first via Room + Flow (SWR) — responsiveness

Goal is UI responsiveness: the History tab was fetch-on-visit and
showed a spinner on every open. Now it paints instantly from a cached
snapshot and refreshes underneath — the native mirror of Flutter's
cacheFirst _historyProvider (alwaysRefresh over the cached_history_
snapshot drift row).

Native implementation (idiomatic, not a drift transliteration):
* CachedHistorySnapshotEntity — single-row table (id=1) holding the
  raw /api/me/history wire JSON + updatedAt. AppDatabase v5→6
  (fallbackToDestructiveMigration rebuilds; cache refills from server).
* CachedHistorySnapshotDao.observe() Flow + upsert; DatabaseModule
  @Provides bridge (the AudioCacheIndexDao lesson — every @Inject dep
  needs a provider).
* HistoryRepository.observeHistory(): Flow<HistoryPage?> decodes the
  blob; refresh() fetches + overwrites the snapshot. Whole page as one
  JSON blob (read-only timestamp-keyed snapshot — no per-row bookkeeping
  for no UX gain), matching Flutter's snapshot shape.
* HistoryTabViewModel: uiState = combine(observeHistory, refreshError)
  — cache-first, SWR, and Error only when there's no cache to show.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 10:33:29 -04:00
parent 14330e99da
commit 4dc80047b2
6 changed files with 136 additions and 46 deletions
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -20,6 +21,7 @@ import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
@@ -57,9 +59,10 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedMutationEntity::class,
CachedResumeStateEntity::class,
CachedHomeIndexEntity::class,
CachedHistorySnapshotEntity::class,
AuthSessionEntity::class,
],
version = 5,
version = 6,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -76,5 +79,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedMutationDao(): CachedMutationDao
abstract fun cachedResumeStateDao(): CachedResumeStateDao
abstract fun cachedHomeIndexDao(): CachedHomeIndexDao
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
abstract fun authSessionDao(): AuthSessionDao
}
@@ -6,6 +6,7 @@ import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -65,6 +66,11 @@ object DatabaseModule {
fun provideCachedHomeIndexDao(db: AppDatabase): CachedHomeIndexDao =
db.cachedHomeIndexDao()
@Provides
@Singleton
fun provideCachedHistorySnapshotDao(db: AppDatabase): CachedHistorySnapshotDao =
db.cachedHistorySnapshotDao()
@Provides
@Singleton
fun provideCachedPlaylistDao(db: AppDatabase): CachedPlaylistDao =
@@ -0,0 +1,18 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedHistorySnapshotDao {
/** Observes the single snapshot row (null until the first fetch lands). */
@Query("SELECT * FROM cached_history_snapshot WHERE id = 1")
fun observe(): Flow<CachedHistorySnapshotEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: CachedHistorySnapshotEntity)
}
@@ -0,0 +1,26 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
*
* Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner.
*/
@Entity(tableName = "cached_history_snapshot")
data class CachedHistorySnapshotEntity(
@PrimaryKey val id: Int = SINGLETON_ID,
val json: String,
val updatedAt: Instant = Clock.System.now(),
) {
companion object {
const val SINGLETON_ID = 1
}
}
@@ -1,8 +1,14 @@
package com.fabledsword.minstrel.history.data
import com.fabledsword.minstrel.api.endpoints.HistoryApi
import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.HistoryPageWire
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
@@ -21,36 +27,53 @@ data class HistoryEntry(
val track: TrackRef,
)
/**
* Read-through accessor for the caller's listening history.
*
* v1 is fetch-on-visit only — no Room caching. The Phase-13 offline
* pass adds a `cached_history_snapshot` JSON-blob mirror so the tab
* paints from disk on cold open and survives connectivity loss
* (matching `flutter_client/lib/library/library_screen.dart`'s
* SWR-style `historyProvider`).
*/
@Singleton
class HistoryRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: HistoryApi = retrofit.create()
suspend fun fetchPage(
limit: Int = HistoryApi.DEFAULT_LIMIT,
offset: Int = 0,
): HistoryPage {
val wire = api.getHistory(limit, offset)
return HistoryPage(
entries = wire.events.map {
HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain())
},
hasMore = wire.hasMore,
)
}
}
data class HistoryPage(
val entries: List<HistoryEntry>,
val hasMore: Boolean,
)
/**
* Cache-first listening history. Mirrors Flutter's `_historyProvider`
* (`cacheFirst` over the `cached_history_snapshot` drift row with
* `alwaysRefresh`) using the native Room + Flow idiom:
*
* - [observeHistory] emits the cached snapshot instantly (null until
* the first fetch lands), so the History tab paints from disk on
* cold open with no blocking spinner.
* - [refresh] pulls `/api/me/history`, stores the raw wire JSON as
* the single snapshot row, and the Flow re-emits — SWR. Errors
* propagate to the caller (the VM surfaces them only when there's
* no cache to show).
*
* The whole page is stored as one JSON blob (single-row table) rather
* than per-row Room entities — history is a read-only timestamp-keyed
* snapshot, so a blob is the simplest faithful mirror of the Drift
* snapshot and avoids ordering/paging bookkeeping for no UX gain.
*/
@Singleton
class HistoryRepository @Inject constructor(
private val snapshotDao: CachedHistorySnapshotDao,
private val json: Json,
retrofit: Retrofit,
) {
private val api: HistoryApi = retrofit.create()
fun observeHistory(): Flow<HistoryPage?> =
snapshotDao.observe().map { row ->
row?.let { json.decodeFromString<HistoryPageWire>(it.json).toDomain() }
}
/** Fetch the latest history and overwrite the cached snapshot. */
suspend fun refresh() {
val wire = api.getHistory()
val blob = json.encodeToString(HistoryPageWire.serializer(), wire)
snapshotDao.upsert(CachedHistorySnapshotEntity(json = blob))
}
}
private fun HistoryPageWire.toDomain(): HistoryPage = HistoryPage(
entries = events.map {
HistoryEntry(id = it.id, playedAt = it.playedAt, track = it.track.toDomain())
},
hasMore = hasMore,
)
@@ -35,8 +35,10 @@ import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
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.stateIn
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
import java.time.ZoneId
@@ -48,6 +50,8 @@ import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface HistoryUiState {
data object Loading : HistoryUiState
data object Empty : HistoryUiState
@@ -63,27 +67,36 @@ class HistoryTabViewModel @Inject constructor(
private val player: PlayerController,
) : ViewModel() {
private val internal = MutableStateFlow<HistoryUiState>(HistoryUiState.Loading)
val uiState: StateFlow<HistoryUiState> = internal.asStateFlow()
private val refreshError = MutableStateFlow<String?>(null)
/**
* Cache-first: emits the cached snapshot instantly (paints from
* disk on cold open), then SWR-refreshes underneath. Shows Error
* only when there's nothing cached AND the refresh failed — a
* failed refresh over a populated cache stays silent.
*/
val uiState: StateFlow<HistoryUiState> =
combine(repository.observeHistory(), refreshError) { page, err ->
when {
page != null && page.entries.isNotEmpty() -> HistoryUiState.Success(page.entries)
page != null -> HistoryUiState.Empty
err != null -> HistoryUiState.Error(err)
else -> HistoryUiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = HistoryUiState.Loading,
)
init {
refresh()
}
fun refresh(): Job = viewModelScope.launch {
internal.value = HistoryUiState.Loading
try {
val page = repository.fetchPage()
internal.value = if (page.entries.isEmpty()) {
HistoryUiState.Empty
} else {
HistoryUiState.Success(page.entries)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = HistoryUiState.Error(ErrorCopy.fromThrowable(e))
}
refreshError.value = null
runCatching { repository.refresh() }
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
/** Single-track play, matching the Flutter history-row behavior. */