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.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<String>,
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)
}
}
@@ -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>(AlbumDetailUiState.Loading)
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 {
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)
}
}
}
@@ -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))
@@ -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>(ArtistDetailUiState.Loading)
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 {
refresh()
}
fun toggleLikeArtist() {
val desired = !artistLiked.value
viewModelScope.launch {
likes.toggleLike(LikesRepository.ENTITY_ARTIST, artistId, desired)
}
}
fun refresh() {
viewModelScope.launch {
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
},
)
}
}