feat(android): Phase 14 — Like buttons on Album + Artist detail

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<Boolean>` (observeIsLiked for the
    album) + `likedTrackIds: StateFlow<Set<String>>` (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<Boolean>` + `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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 18:46:59 -04:00
parent d96f835b5b
commit 7dfe6f1b69
5 changed files with 169 additions and 18 deletions
@@ -49,6 +49,7 @@ import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -78,12 +79,20 @@ fun AlbumDetailScreen(
title = "Couldn't load album", title = "Couldn't load album",
body = s.message, body = s.message,
) )
is AlbumDetailUiState.Success -> AlbumBody( is AlbumDetailUiState.Success -> {
detail = s.detail, val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
onPlayAll = { viewModel.play(startTrackId = null) }, val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
onShuffleAll = { viewModel.play(startTrackId = null) }, AlbumBody(
onTrackClick = { id -> viewModel.play(startTrackId = id) }, 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 @Composable
private fun AlbumBody( private fun AlbumBody(
detail: AlbumDetailRef, detail: AlbumDetailRef,
albumLiked: Boolean,
likedTrackIds: Set<String>,
onPlayAll: () -> Unit, onPlayAll: () -> Unit,
onShuffleAll: () -> Unit, onShuffleAll: () -> Unit,
onTrackClick: (String) -> Unit, onTrackClick: (String) -> Unit,
onToggleAlbumLike: () -> Unit,
onToggleTrackLike: (String) -> Unit,
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 140.dp), 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() } item { HorizontalDivider() }
items(items = detail.tracks, key = { it.id }) { track -> 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() HorizontalDivider()
} }
if (detail.tracks.isEmpty()) { if (detail.tracks.isEmpty()) {
@@ -127,8 +153,10 @@ private fun AlbumBody(
@Composable @Composable
private fun AlbumHeader( private fun AlbumHeader(
album: AlbumRef, album: AlbumRef,
albumLiked: Boolean,
onPlayAll: () -> Unit, onPlayAll: () -> Unit,
onShuffleAll: () -> Unit, onShuffleAll: () -> Unit,
onToggleAlbumLike: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -165,6 +193,7 @@ private fun AlbumHeader(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
LikeButton(liked = albumLiked, onToggle = onToggleAlbumLike)
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
@@ -216,12 +245,17 @@ private fun trackCountLine(album: AlbumRef): String {
} }
@Composable @Composable
private fun TrackRow(track: TrackRef, onClick: () -> Unit) { private fun TrackRow(
track: TrackRef,
liked: Boolean,
onClick: () -> Unit,
onToggleLike: () -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -254,6 +288,7 @@ private fun TrackRow(track: TrackRef, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
) )
LikeButton(liked = liked, onToggle = onToggleLike)
} }
} }
@@ -5,16 +5,22 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.AlbumDetailRef import com.fabledsword.minstrel.models.AlbumDetailRef
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface AlbumDetailUiState { sealed interface AlbumDetailUiState {
data object Loading : AlbumDetailUiState data object Loading : AlbumDetailUiState
data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState data class Success(val detail: AlbumDetailRef) : AlbumDetailUiState
@@ -24,6 +30,7 @@ sealed interface AlbumDetailUiState {
@HiltViewModel @HiltViewModel
class AlbumDetailViewModel @Inject constructor( class AlbumDetailViewModel @Inject constructor(
private val repository: LibraryRepository, private val repository: LibraryRepository,
private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -34,6 +41,23 @@ class AlbumDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading) private val internal = MutableStateFlow<AlbumDetailUiState>(AlbumDetailUiState.Loading)
val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow() val uiState: StateFlow<AlbumDetailUiState> = internal.asStateFlow()
val albumLiked: StateFlow<Boolean> =
likes.observeIsLiked(LikesRepository.ENTITY_ALBUM, albumId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
val likedTrackIds: StateFlow<Set<String>> =
likes.observeLikedTracks()
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = emptySet(),
)
init { init {
refresh() refresh()
} }
@@ -65,4 +89,18 @@ class AlbumDetailViewModel @Inject constructor(
source = "album:${detail.album.id}", 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)
}
}
} }
@@ -48,6 +48,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LikeButton
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -76,11 +77,16 @@ fun ArtistDetailScreen(
title = "Couldn't load artist", title = "Couldn't load artist",
body = s.message, body = s.message,
) )
is ArtistDetailUiState.Success -> ArtistBody( is ArtistDetailUiState.Success -> {
detail = s.detail, val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
onPlay = viewModel::playArtist, ArtistBody(
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) }, 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 @Composable
private fun ArtistBody( private fun ArtistBody(
detail: ArtistDetailRef, detail: ArtistDetailRef,
artistLiked: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
onToggleLike: () -> Unit,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
@@ -105,7 +113,12 @@ private fun ArtistBody(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
ArtistHeader(artist = detail.artist, onPlay = onPlay) ArtistHeader(
artist = detail.artist,
artistLiked = artistLiked,
onPlay = onPlay,
onToggleLike = onToggleLike,
)
} }
if (detail.albums.isEmpty()) { if (detail.albums.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@@ -120,13 +133,18 @@ private fun ArtistBody(
} }
@Composable @Composable
private fun ArtistHeader(artist: ArtistRef, onPlay: () -> Unit) { private fun ArtistHeader(
artist: ArtistRef,
artistLiked: Boolean,
onPlay: () -> Unit,
onToggleLike: () -> Unit,
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
ArtistAvatar(artist) ArtistAvatar(artist)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@@ -145,6 +163,7 @@ private fun ArtistHeader(artist: ArtistRef, onPlay: () -> Unit) {
) )
} }
} }
LikeButton(liked = artistLiked, onToggle = onToggleLike)
Button(onClick = onPlay) { Button(onClick = onPlay) {
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -5,16 +5,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.fabledsword.minstrel.library.data.LibraryRepository import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.ArtistDetailRef import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
sealed interface ArtistDetailUiState { sealed interface ArtistDetailUiState {
data object Loading : ArtistDetailUiState data object Loading : ArtistDetailUiState
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
@@ -24,6 +29,7 @@ sealed interface ArtistDetailUiState {
@HiltViewModel @HiltViewModel
class ArtistDetailViewModel @Inject constructor( class ArtistDetailViewModel @Inject constructor(
private val repository: LibraryRepository, private val repository: LibraryRepository,
private val likes: LikesRepository,
private val player: PlayerController, private val player: PlayerController,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
@@ -34,10 +40,25 @@ class ArtistDetailViewModel @Inject constructor(
private val internal = MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading) private val internal = MutableStateFlow<ArtistDetailUiState>(ArtistDetailUiState.Loading)
val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow() val uiState: StateFlow<ArtistDetailUiState> = internal.asStateFlow()
val artistLiked: StateFlow<Boolean> =
likes.observeIsLiked(LikesRepository.ENTITY_ARTIST, artistId)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = false,
)
init { init {
refresh() refresh()
} }
fun toggleLikeArtist() {
val desired = !artistLiked.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_ARTIST, artistId, desired)
}
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
internal.value = ArtistDetailUiState.Loading internal.value = ArtistDetailUiState.Loading
@@ -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
},
)
}
}