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