From 7dfe6f1b691896594b24c41300709beead607754 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 18:46:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2014=20=E2=80=94=20Like?= =?UTF-8?q?=20buttons=20on=20Album=20+=20Artist=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the silent gap where there was no UI to like anything. Now the Liked tab can actually grow from in-app actions, not just from the /api/likes/ids sync seed. New: - shared/widgets/LikeButton.kt — heart toggle composable. Caller-owned state: (liked: Boolean, onToggle: () -> Unit). Tinted with M3 primary slot on liked, onSurfaceVariant on unliked — tracks the light/dark theme without per-mode branches. Modified: - library/ui/AlbumDetailViewModel.kt — injects LikesRepository; exposes `albumLiked: StateFlow` (observeIsLiked for the album) + `likedTrackIds: StateFlow>` (observeLikedTracks mapped to a Set for O(1) row-level lookup). `toggleLikeAlbum()` + `toggleLikeTrack(id)` route through the repo's optimistic-write + MutationQueue path. - library/ui/AlbumDetailScreen.kt — LikeButton on the header next to the cover/title block, LikeButton on every track row after the duration. Track row vertical padding tightened from 12dp → 8dp to give the heart breathing room. - library/ui/ArtistDetailViewModel.kt — injects LikesRepository; exposes `artistLiked: StateFlow` + `toggleLikeArtist()`. - library/ui/ArtistDetailScreen.kt — LikeButton on the header between the name column and Play button. PlaylistDetail per-track likes follows in a small follow-up — same pattern, just hadn't been integrated yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minstrel/library/ui/AlbumDetailScreen.kt | 55 +++++++++++++++---- .../library/ui/AlbumDetailViewModel.kt | 38 +++++++++++++ .../minstrel/library/ui/ArtistDetailScreen.kt | 35 +++++++++--- .../library/ui/ArtistDetailViewModel.kt | 21 +++++++ .../minstrel/shared/widgets/LikeButton.kt | 38 +++++++++++++ 5 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt index a967f31c..4a37a772 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailScreen.kt @@ -49,6 +49,7 @@ import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LikeButton import com.fabledsword.minstrel.theme.FabledSwordFlatTokens @OptIn(ExperimentalMaterial3Api::class) @@ -78,12 +79,20 @@ fun AlbumDetailScreen( title = "Couldn't load album", body = s.message, ) - is AlbumDetailUiState.Success -> AlbumBody( - detail = s.detail, - onPlayAll = { viewModel.play(startTrackId = null) }, - onShuffleAll = { viewModel.play(startTrackId = null) }, - onTrackClick = { id -> viewModel.play(startTrackId = id) }, - ) + is AlbumDetailUiState.Success -> { + val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle() + val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle() + AlbumBody( + detail = s.detail, + albumLiked = albumLiked, + likedTrackIds = likedTrackIds, + onPlayAll = { viewModel.play(startTrackId = null) }, + onShuffleAll = { viewModel.play(startTrackId = null) }, + onTrackClick = { id -> viewModel.play(startTrackId = id) }, + onToggleAlbumLike = viewModel::toggleLikeAlbum, + onToggleTrackLike = viewModel::toggleLikeTrack, + ) + } } } } @@ -97,18 +106,35 @@ private fun titleFor(state: AlbumDetailUiState): String = when (state) { @Composable private fun AlbumBody( detail: AlbumDetailRef, + albumLiked: Boolean, + likedTrackIds: Set, onPlayAll: () -> Unit, onShuffleAll: () -> Unit, onTrackClick: (String) -> Unit, + onToggleAlbumLike: () -> Unit, + onToggleTrackLike: (String) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 140.dp), ) { - item { AlbumHeader(detail.album, onPlayAll, onShuffleAll) } + item { + AlbumHeader( + album = detail.album, + albumLiked = albumLiked, + onPlayAll = onPlayAll, + onShuffleAll = onShuffleAll, + onToggleAlbumLike = onToggleAlbumLike, + ) + } item { HorizontalDivider() } items(items = detail.tracks, key = { it.id }) { track -> - TrackRow(track = track, onClick = { onTrackClick(track.id) }) + TrackRow( + track = track, + liked = track.id in likedTrackIds, + onClick = { onTrackClick(track.id) }, + onToggleLike = { onToggleTrackLike(track.id) }, + ) HorizontalDivider() } if (detail.tracks.isEmpty()) { @@ -127,8 +153,10 @@ private fun AlbumBody( @Composable private fun AlbumHeader( album: AlbumRef, + albumLiked: Boolean, onPlayAll: () -> Unit, onShuffleAll: () -> Unit, + onToggleAlbumLike: () -> Unit, ) { Column( modifier = Modifier @@ -165,6 +193,7 @@ private fun AlbumHeader( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + LikeButton(liked = albumLiked, onToggle = onToggleAlbumLike) } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( @@ -216,12 +245,17 @@ private fun trackCountLine(album: AlbumRef): String { } @Composable -private fun TrackRow(track: TrackRef, onClick: () -> Unit) { +private fun TrackRow( + track: TrackRef, + liked: Boolean, + onClick: () -> Unit, + onToggleLike: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -254,6 +288,7 @@ private fun TrackRow(track: TrackRef, onClick: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 12.dp), ) + LikeButton(liked = liked, onToggle = onToggleLike) } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt index 1713e32f..e8970724 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/AlbumDetailViewModel.kt @@ -5,16 +5,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + sealed interface AlbumDetailUiState { data object Loading : AlbumDetailUiState data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState @@ -24,6 +30,7 @@ sealed interface AlbumDetailUiState { @HiltViewModel class AlbumDetailViewModel @Inject constructor( private val repository: LibraryRepository, + private val likes: LikesRepository, private val player: PlayerController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -34,6 +41,23 @@ class AlbumDetailViewModel @Inject constructor( private val internal = MutableStateFlow(AlbumDetailUiState.Loading) val uiState: StateFlow = internal.asStateFlow() + val albumLiked: StateFlow = + likes.observeIsLiked(LikesRepository.ENTITY_ALBUM, albumId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + + val likedTrackIds: StateFlow> = + likes.observeLikedTracks() + .map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = emptySet(), + ) + init { refresh() } @@ -65,4 +89,18 @@ class AlbumDetailViewModel @Inject constructor( source = "album:${detail.album.id}", ) } + + fun toggleLikeAlbum() { + val desired = !albumLiked.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_ALBUM, albumId, desired) + } + } + + fun toggleLikeTrack(trackId: String) { + val desired = trackId !in likedTrackIds.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired) + } + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt index 3c119f26..b63b3a38 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailScreen.kt @@ -48,6 +48,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.shared.widgets.EmptyState +import com.fabledsword.minstrel.shared.widgets.LikeButton @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -76,11 +77,16 @@ fun ArtistDetailScreen( title = "Couldn't load artist", body = s.message, ) - is ArtistDetailUiState.Success -> ArtistBody( - detail = s.detail, - onPlay = viewModel::playArtist, - onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, - ) + is ArtistDetailUiState.Success -> { + val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle() + ArtistBody( + detail = s.detail, + artistLiked = artistLiked, + onPlay = viewModel::playArtist, + onToggleLike = viewModel::toggleLikeArtist, + onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, + ) + } } } } @@ -94,7 +100,9 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) { @Composable private fun ArtistBody( detail: ArtistDetailRef, + artistLiked: Boolean, onPlay: () -> Unit, + onToggleLike: () -> Unit, onAlbumClick: (String) -> Unit, ) { LazyVerticalGrid( @@ -105,7 +113,12 @@ private fun ArtistBody( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { item(span = { GridItemSpan(maxLineSpan) }) { - ArtistHeader(artist = detail.artist, onPlay = onPlay) + ArtistHeader( + artist = detail.artist, + artistLiked = artistLiked, + onPlay = onPlay, + onToggleLike = onToggleLike, + ) } if (detail.albums.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { @@ -120,13 +133,18 @@ private fun ArtistBody( } @Composable -private fun ArtistHeader(artist: ArtistRef, onPlay: () -> Unit) { +private fun ArtistHeader( + artist: ArtistRef, + artistLiked: Boolean, + onPlay: () -> Unit, + onToggleLike: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { ArtistAvatar(artist) Column(modifier = Modifier.weight(1f)) { @@ -145,6 +163,7 @@ private fun ArtistHeader(artist: ArtistRef, onPlay: () -> Unit) { ) } } + LikeButton(liked = artistLiked, onToggle = onToggleLike) Button(onClick = onPlay) { Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt index 31680284..7363775b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/ui/ArtistDetailViewModel.kt @@ -5,16 +5,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import com.fabledsword.minstrel.library.data.LibraryRepository +import com.fabledsword.minstrel.likes.data.LikesRepository import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.player.PlayerController import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val SHARE_STOP_TIMEOUT_MS = 5_000L + sealed interface ArtistDetailUiState { data object Loading : ArtistDetailUiState data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState @@ -24,6 +29,7 @@ sealed interface ArtistDetailUiState { @HiltViewModel class ArtistDetailViewModel @Inject constructor( private val repository: LibraryRepository, + private val likes: LikesRepository, private val player: PlayerController, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -34,10 +40,25 @@ class ArtistDetailViewModel @Inject constructor( private val internal = MutableStateFlow(ArtistDetailUiState.Loading) val uiState: StateFlow = internal.asStateFlow() + val artistLiked: StateFlow = + likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), + initialValue = false, + ) + init { refresh() } + fun toggleLikeArtist() { + val desired = !artistLiked.value + viewModelScope.launch { + likes.toggleLike(LikesRepository.ENTITY_ARTIST, artistId, desired) + } + } + fun refresh() { viewModelScope.launch { internal.value = ArtistDetailUiState.Loading diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt new file mode 100644 index 00000000..3ec7722f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/LikeButton.kt @@ -0,0 +1,38 @@ +package com.fabledsword.minstrel.shared.widgets + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.composables.icons.lucide.Heart +import com.composables.icons.lucide.Lucide + +/** + * Heart toggle. Callers own the state — pass `liked: Boolean` + + * `onToggle: () -> Unit`. The optimistic-write rule lives in the + * caller's ViewModel (typically observeIsLiked + toggleLike from + * LikesRepository), not here. + * + * Flutter equivalent is `flutter_client/lib/likes/like_button.dart`. + * That one tints the heart with the accent color; we do the same via + * the M3 primary slot so light/dark mode tracks the theme. + */ +@Composable +fun LikeButton( + liked: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onToggle, modifier = modifier) { + Icon( + imageVector = Lucide.Heart, + contentDescription = if (liked) "Unlike" else "Like", + tint = if (liked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +}