feat(android): Phase 8 — likes write path + mutation queue + Liked tab

Replaces the Library Liked-tab ComingSoon placeholder with a real
hydrated view of liked artists / albums / tracks, plus the
offline-first toggle-like write path.

New:
  - cache/mutations/MutationQueue.kt — minimal write-side of the
    offline mutation queue (Phase 8 v1 only enqueues; the
    connectivity-aware drain lands with Phase 12.2 SyncController).
    Defines MutationKind.LIKE_TOGGLE + LikeTogglePayload + an
    enqueueLikeToggle helper.
  - api/endpoints/LikesApi.kt — Retrofit (POST/DELETE
    `api/likes/{kind}/{id}` + GET `/api/likes/ids`). Plural-segment
    mapping ("artist" → "artists") lives in LikesRepository.
  - models/wire/LikesWire.kt — @Serializable LikedIdsWire matching
    `internal/api/likes.go likedIDsResponse`.
  - likes/data/LikesRepository.kt — observe (hydrated artists /
    albums / tracks via the existing CachedAlbumDao /
    CachedArtistDao / CachedTrackDao) + observeIsLiked +
    toggleLike + refreshIds. Local userId is hardcoded as "local"
    behind LOCAL_USER_ID; TODO marker for the Phase-11 swap to
    AuthStore.userId. Write path is optimistic Room mutation →
    best-effort REST → enqueue-on-failure, per
    `feedback_offline_first_for_server_writes`.
  - likes/ui/LikedTab.kt — VM + UiState + composable. Three sections
    (Artists horizontal row, Albums horizontal row, Tracks list) +
    "No likes yet" empty state. Tile taps navigate to detail screens;
    track-row taps are disabled until Phase 9 wires history play-from
    -here (placeholder so the row reads correctly without an
    unfinished playback affordance).

Modified:
  - cache/db/DatabaseModule.kt — @Provides for CachedLikeDao +
    CachedMutationDao (DAOs existed but had no consumer until now).
  - library/ui/LibraryScreen.kt — TAB_LIKED dispatch swaps from
    ComingSoonTab to `LikedTab(navController)`.

Like buttons on detail screens come with the AlbumDetail / ArtistDetail
real-screen build, which is a separate later commit (they're still
ComingSoon stubs in MinstrelNavGraph).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 22:39:58 -04:00
parent 6727bed35e
commit d047d6e046
7 changed files with 491 additions and 5 deletions
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.LikedIdsWire
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/likes`. Mirrors
* `flutter_client/lib/api/endpoints/likes.dart`.
*
* Path segment `kind` is one of "artists" | "albums" | "tracks"
* (plural, matching the server route). The Repository hides that
* pluralization mapping behind a typed enum so callers pass
* "artist" / "album" / "track" everywhere else.
*
* Paged list endpoints (`/api/likes/{kind}?limit&offset`) are
* intentionally omitted — the Liked tab renders straight from
* `cached_likes`, hydrated against the per-entity caches.
*/
interface LikesApi {
@POST("api/likes/{kind}/{id}")
suspend fun like(@Path("kind") kind: String, @Path("id") id: String)
@DELETE("api/likes/{kind}/{id}")
suspend fun unlike(@Path("kind") kind: String, @Path("id") id: String)
@GET("api/likes/ids")
suspend fun ids(): LikedIdsWire
}
@@ -6,6 +6,8 @@ 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.CachedHomeIndexDao
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.CachedResumeStateDao
@@ -70,5 +72,14 @@ object DatabaseModule {
fun provideCachedPlaylistTrackDao(db: AppDatabase): CachedPlaylistTrackDao =
db.cachedPlaylistTrackDao()
@Provides
@Singleton
fun provideCachedLikeDao(db: AppDatabase): CachedLikeDao = db.cachedLikeDao()
@Provides
@Singleton
fun provideCachedMutationDao(db: AppDatabase): CachedMutationDao =
db.cachedMutationDao()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
/**
* Stable mutation kinds the queue knows how to replay. Strings are
* persisted in `cached_mutations.kind` so renaming a variant breaks
* the on-disk queue — only ever add new kinds, never rename.
*/
object MutationKind {
const val LIKE_TOGGLE: String = "like_toggle"
}
/**
* Persisted payload for `MutationKind.LIKE_TOGGLE`. `entityType` is
* one of "artist" | "album" | "track" matching the server's like-path
* segment; `desiredState` is the *target* (true = liked, false = not
* liked) so a single replay slot can collapse repeated toggles.
*/
@Serializable
data class LikeTogglePayload(
val entityType: String,
val entityId: String,
val desiredState: Boolean,
)
/**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
* enqueues — the connectivity-aware replayer that drains the queue
* lands with Phase 12.2's SyncController.
*
* Callers (LikesRepository et al.) follow the optimistic-write
* pattern: mutate Room first so the UI reflects the change
* immediately, fire the REST call as a best-effort attempt, and on
* failure enqueue here so the replayer eventually pushes it through.
* This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget.
*/
@Singleton
class MutationQueue @Inject constructor(
private val dao: CachedMutationDao,
private val json: Json,
) {
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),
),
),
)
}
@@ -30,6 +30,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.likes.ui.LikedTab
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail
@@ -98,11 +99,7 @@ fun LibraryScreen(
body = "Recent plays will appear here once /api/me/history " +
"wiring lands in Phase 9.",
)
TAB_LIKED -> ComingSoonTab(
title = "Liked",
body = "Liked artists, albums, and tracks will appear here " +
"once the like-write path lands in Phase 8.",
)
TAB_LIKED -> LikedTab(navController = navController)
TAB_HIDDEN -> ComingSoonTab(
title = "Hidden",
body = "Tracks you've flagged as bad rips, wrong tags, or " +
@@ -0,0 +1,134 @@
package com.fabledsword.minstrel.likes.data
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Cache-first reads + offline-first writes for likes. Mirrors the
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
* pattern.
*
* Local `userId` discriminator is a constant for now (`LOCAL_USER_ID`)
* because the AuthController hookup lands with Phase 11. Single-user
* device is the only shape we target; if multi-tenant on one device
* ever shows up, swap this constant for `AuthStore.userId.value`
* everywhere and migrate the cached_likes rows.
*
* Write path follows `feedback_offline_first_for_server_writes` —
* never fire-and-forget:
* 1. Optimistic Room mutation (UI flips instantly).
* 2. Best-effort server call.
* 3. On exception → enqueue in MutationQueue for the Phase 12.2
* replayer to drain when connectivity returns.
*
* Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes
* through the existing Library DAOs — IDs without cached entity rows
* are silently dropped (the SyncController fills the gap async).
*/
@Singleton
class LikesRepository @Inject constructor(
private val likeDao: CachedLikeDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val mutationQueue: MutationQueue,
retrofit: Retrofit,
) {
private val api: LikesApi = retrofit.create()
// ── Reads ─────────────────────────────────────────────────────────
fun observeLikedArtists(): Flow<List<ArtistRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids ->
ids.mapNotNull { artistDao.getById(it)?.toDomain() }
}
fun observeLikedAlbums(): Flow<List<AlbumRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids ->
ids.mapNotNull { albumDao.getById(it)?.toDomain() }
}
fun observeLikedTracks(): Flow<List<TrackRef>> =
likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids ->
ids.mapNotNull { trackDao.getById(it)?.toDomain() }
}
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
likeDao.observeIsLiked(LOCAL_USER_ID, entityType, entityId)
// ── Writes (optimistic local → best-effort REST → enqueue on fail) ──
/**
* Toggle like state for [entityType]/[entityId]. UI doesn't need to
* await this; the Flow re-emits immediately from the optimistic
* Room write. Returns true if the call reached the server, false
* if it got enqueued for later replay.
*/
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
// 1. Optimistic Room mutation.
if (desiredState) {
likeDao.upsertAll(
listOf(CachedLikeEntity(LOCAL_USER_ID, entityType, entityId)),
)
} else {
likeDao.delete(LOCAL_USER_ID, entityType, entityId)
}
// 2. Best-effort REST call; 3. enqueue on failure.
val kindPath = serverPathFor(entityType)
return try {
if (desiredState) api.like(kindPath, entityId) else api.unlike(kindPath, entityId)
true
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState)
false
}
}
/**
* Pulls `GET /api/likes/ids` and reconciles `cached_likes`. Called
* by the LikedTab ViewModel on init (and by the SyncController in
* Phase 12). Adds rows for IDs the server has but we don't; the
* delete-of-stale-rows pass lives in the SyncController where the
* full reconciliation runs.
*/
suspend fun refreshIds() {
val wire = api.ids()
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(LOCAL_USER_ID, ENTITY_TRACK, it) }
likeDao.upsertAll(rows)
}
companion object {
// TODO(Phase 11): swap for AuthStore.userId.value once auth lands.
const val LOCAL_USER_ID: String = "local"
const val ENTITY_ARTIST: String = "artist"
const val ENTITY_ALBUM: String = "album"
const val ENTITY_TRACK: String = "track"
private fun serverPathFor(entityType: String): String = when (entityType) {
ENTITY_ARTIST -> "artists"
ENTITY_ALBUM -> "albums"
ENTITY_TRACK -> "tracks"
else -> error("Unknown entity type: $entityType")
}
}
}
@@ -0,0 +1,231 @@
package com.fabledsword.minstrel.likes.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
// ─── State ───────────────────────────────────────────────────────────
data class LikedSections(
val artists: List<ArtistRef> = emptyList(),
val albums: List<AlbumRef> = emptyList(),
val tracks: List<TrackRef> = emptyList(),
) {
val isAllEmpty: Boolean
get() = artists.isEmpty() && albums.isEmpty() && tracks.isEmpty()
}
sealed interface LikedTabUiState {
data object Loading : LikedTabUiState
data object Empty : LikedTabUiState
data class Success(val sections: LikedSections) : LikedTabUiState
data class Error(val message: String) : LikedTabUiState
}
// ─── ViewModel ───────────────────────────────────────────────────────
@HiltViewModel
class LikedTabViewModel @Inject constructor(
private val repository: LikesRepository,
) : ViewModel() {
init {
viewModelScope.launch {
runCatching { repository.refreshIds() }
}
}
val uiState: StateFlow<LikedTabUiState> = combine(
repository.observeLikedArtists(),
repository.observeLikedAlbums(),
repository.observeLikedTracks(),
) { artists, albums, tracks ->
val sections = LikedSections(artists, albums, tracks)
if (sections.isAllEmpty) LikedTabUiState.Empty else LikedTabUiState.Success(sections)
}
.catch { e -> emit(LikedTabUiState.Error(e.message ?: "Liked load failed")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = LikedTabUiState.Loading,
)
}
// ─── Composable ──────────────────────────────────────────────────────
@Composable
fun LikedTab(
navController: NavHostController,
viewModel: LikedTabViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
LikedTabUiState.Loading -> LoadingCentered()
LikedTabUiState.Empty -> EmptyState(
title = "No likes yet",
body = "Tap the heart on an artist, album, or track to start " +
"building your liked collection.",
)
is LikedTabUiState.Error -> EmptyState(
title = "Couldn't load likes",
body = s.message,
)
is LikedTabUiState.Success -> LikedContent(
sections = s.sections,
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
)
}
}
}
@Composable
private fun LikedContent(
sections: LikedSections,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp),
) {
if (sections.artists.isNotEmpty()) {
item {
SectionHeader("Artists", sections.artists.size)
}
item {
HorizontalScrollRow(title = "") {
items(items = sections.artists, key = { "art-${it.id}" }) { artist ->
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
}
}
}
}
if (sections.albums.isNotEmpty()) {
item {
SectionHeader("Albums", sections.albums.size)
}
item {
HorizontalScrollRow(title = "") {
items(items = sections.albums, key = { "alb-${it.id}" }) { album ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
}
}
}
if (sections.tracks.isNotEmpty()) {
item {
SectionHeader("Tracks", sections.tracks.size)
}
items(items = sections.tracks, key = { "trk-${it.id}" }) { track ->
LikedTrackRow(track = track)
HorizontalDivider()
}
}
item { Spacer(Modifier.height(96.dp)) }
}
}
@Composable
private fun SectionHeader(label: String, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "$count",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun LikedTrackRow(track: TrackRef) {
Column(
modifier = Modifier
.fillMaxWidth()
// Track-row taps will route to the player queue once Phase 9
// wires history + recently-played tap-to-play; for now the
// row is informational so swiping the tab still feels
// responsive without an unfinished playback affordance.
.clickable(enabled = false, onClick = {})
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = track.artistName.ifEmpty { track.albumTitle },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape of `GET /api/likes/ids`. Server returns the full ID set
* of items the caller has liked, partitioned by entity type — used by
* the LikesRepository to do a one-shot refill of `cached_likes` on
* first launch and after sign-in.
*
* Empty defaults so missing keys don't throw on decode.
*/
@Serializable
data class LikedIdsWire(
@SerialName("artist_ids") val artistIds: List<String> = emptyList(),
@SerialName("album_ids") val albumIds: List<String> = emptyList(),
@SerialName("track_ids") val trackIds: List<String> = emptyList(),
)