diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt new file mode 100644 index 00000000..e3c914ce --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/LikesApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt index 0647c3ab..eb1c22c3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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" } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt new file mode 100644 index 00000000..13226834 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -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), + ), + ), + ) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt index 2d897a4a..e9e59657 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/LibraryScreen.kt @@ -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 " + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt new file mode 100644 index 00000000..8297f583 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -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> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ARTIST).map { ids -> + ids.mapNotNull { artistDao.getById(it)?.toDomain() } + } + + fun observeLikedAlbums(): Flow> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_ALBUM).map { ids -> + ids.mapNotNull { albumDao.getById(it)?.toDomain() } + } + + fun observeLikedTracks(): Flow> = + likeDao.observeLikedIdsOfType(LOCAL_USER_ID, ENTITY_TRACK).map { ids -> + ids.mapNotNull { trackDao.getById(it)?.toDomain() } + } + + fun observeIsLiked(entityType: String, entityId: String): Flow = + 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() + 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") + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt new file mode 100644 index 00000000..6e730c41 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/ui/LikedTab.kt @@ -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 = emptyList(), + val albums: List = emptyList(), + val tracks: List = 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 = 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikesWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikesWire.kt new file mode 100644 index 00000000..5ae279c5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/LikesWire.kt @@ -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 = emptyList(), + @SerialName("album_ids") val albumIds: List = emptyList(), + @SerialName("track_ids") val trackIds: List = emptyList(), +)