feat(android): LikedTab inline heart + Settings sign-out confirmation

Two more audit v3 cleanup items:

§4.11 — LikedTab track rows lacked an inline LikeButton. Users had
to open the kebab menu to unlike a track — two taps for what should
be one. Added LikeButton(liked = true) next to the TrackActionsButton;
tap unlikes via LikedTabViewModel.unlikeTrack which routes through
LikesRepository.toggleLike (and therefore the MutationQueue, so
offline taps replay).

§4.23 — SettingsScreen Sign Out tapped immediately wiped local
state with no confirmation. Now: AlertDialog with "Sign out of this
server? Your downloaded music and settings on this device will be
removed." and an errorContainer-tinted confirm button. Cancel by
default for accidental taps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 21:02:41 -04:00
parent d8459a2674
commit 05c7d922c4
2 changed files with 53 additions and 1 deletions
@@ -38,6 +38,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.player.PlayerController import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.widgets.EmptyState import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
@@ -88,6 +89,16 @@ class LikedTabViewModel @Inject constructor(
runCatching { repository.refreshIds() } runCatching { repository.refreshIds() }
} }
/**
* Unlike a track from inline heart-tap. The LikesRepository write
* routes through the MutationQueue so offline taps replay later.
*/
fun unlikeTrack(trackId: String) {
viewModelScope.launch {
repository.toggleLike(LikesRepository.ENTITY_TRACK, trackId, liked = false)
}
}
val uiState: StateFlow<LikedTabUiState> = combine( val uiState: StateFlow<LikedTabUiState> = combine(
repository.observeLikedArtists(), repository.observeLikedArtists(),
repository.observeLikedAlbums(), repository.observeLikedAlbums(),
@@ -148,6 +159,7 @@ fun LikedTab(
onTrackClick = { index -> onTrackClick = { index ->
viewModel.playTracks(s.sections.tracks, index) viewModel.playTracks(s.sections.tracks, index)
}, },
onUnlike = viewModel::unlikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) }, onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) }, onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
) )
@@ -162,6 +174,7 @@ private fun LikedContent(
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onTrackClick: (Int) -> Unit, onTrackClick: (Int) -> Unit,
onUnlike: (String) -> Unit,
onNavigateToAlbum: (String) -> Unit, onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit, onNavigateToArtist: (String) -> Unit,
) { ) {
@@ -202,6 +215,7 @@ private fun LikedContent(
track = track, track = track,
nowPlaying = track.id == playingTrackId, nowPlaying = track.id == playingTrackId,
onClick = { onTrackClick(index) }, onClick = { onTrackClick(index) },
onUnlike = { onUnlike(track.id) },
onNavigateToAlbum = onNavigateToAlbum, onNavigateToAlbum = onNavigateToAlbum,
onNavigateToArtist = onNavigateToArtist, onNavigateToArtist = onNavigateToArtist,
) )
@@ -239,6 +253,7 @@ private fun LikedTrackRow(
track: TrackRef, track: TrackRef,
nowPlaying: Boolean, nowPlaying: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onUnlike: () -> Unit,
onNavigateToAlbum: (String) -> Unit, onNavigateToAlbum: (String) -> Unit,
onNavigateToArtist: (String) -> Unit, onNavigateToArtist: (String) -> Unit,
) { ) {
@@ -272,6 +287,7 @@ private fun LikedTrackRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
LikeButton(liked = true, onToggle = onUnlike)
TrackActionsButton( TrackActionsButton(
track = track, track = track,
onNavigateToAlbum = onNavigateToAlbum, onNavigateToAlbum = onNavigateToAlbum,
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -26,10 +27,14 @@ import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -61,6 +66,7 @@ fun SettingsScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val themeMode by themeVm.themeMode.collectAsStateWithLifecycle() val themeMode by themeVm.themeMode.collectAsStateWithLifecycle()
var showSignOutConfirm by remember { mutableStateOf(false) }
LaunchedEffect(state.signedOut) { LaunchedEffect(state.signedOut) {
if (state.signedOut) { if (state.signedOut) {
@@ -118,9 +124,39 @@ fun SettingsScreen(
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode) AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
StorageCard() StorageCard()
AboutCard() AboutCard()
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut) SignOutButton(
isSigningOut = state.isSigningOut,
onSignOut = { showSignOutConfirm = true },
)
} }
} }
if (showSignOutConfirm) {
AlertDialog(
onDismissRequest = { showSignOutConfirm = false },
title = { Text("Sign out?") },
text = {
Text(
"Sign out of this server? Your downloaded music and " +
"settings on this device will be removed.",
)
},
confirmButton = {
Button(
onClick = {
showSignOutConfirm = false
viewModel.signOut()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) { Text("Sign out") }
},
dismissButton = {
TextButton(onClick = { showSignOutConfirm = false }) { Text("Cancel") }
},
)
}
} }
@Composable @Composable