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:
@@ -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
|
||||
}
|
||||
+11
@@ -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"
|
||||
}
|
||||
|
||||
+63
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user