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:
@@ -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 =
|
||||
|
||||
Vendored
+18
@@ -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)
|
||||
}
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
}
|
||||
+52
-29
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user