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:
+45
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-8
@@ -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))
|
||||
|
||||
+21
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user