feat(android): Requests polish — Listen / cancel confirm / progress / kind avatar

Audit v2 #13. Four touches to RequestsScreen rows:

* Kind avatar leading icon: Lucide.Disc3 for artist, LibraryBig for
  album, Music for track (matches Flutter's mapping).
* Cancel confirmation AlertDialog — single tap on Cancel used to be
  irreversible; now shows "Cancel '<name>'? Lidarr will stop searching
  for it." with Cancel / Keep buttons.
* Ingest progress text below the status pill when importedAlbumCount
  or importedTrackCount > 0: "2 albums · 14 tracks ingested".
* Listen OutlinedButton on completed rows when matchedAlbumId or
  matchedArtistId resolves; routes to AlbumDetail (preferred) or
  ArtistDetail. Track matches route through AlbumDetail since the
  client has no TrackDetail screen.

navController.navigate takes the route object directly. Because the
listenRoute can be AlbumDetail or ArtistDetail (both @Serializable
route types from nav/Routes.kt), the callback signature is (Any) ->
Unit and the screen passes it straight to navigate().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 12:52:25 -04:00
parent ec585997ae
commit 49a5324be8
@@ -9,19 +9,26 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -30,10 +37,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Disc3
import com.composables.icons.lucide.LibraryBig
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@@ -83,6 +96,7 @@ fun RequestsScreen(
is RequestsUiState.Success -> RequestList(
rows = s.requests,
onCancel = viewModel::cancel,
onListen = { id -> navController.navigate(id) },
)
}
}
@@ -90,17 +104,30 @@ fun RequestsScreen(
}
@Composable
private fun RequestList(rows: List<RequestRef>, onCancel: (String) -> Unit) {
private fun RequestList(
rows: List<RequestRef>,
onCancel: (String) -> Unit,
onListen: (Any) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
RequestRow(req = req, onCancel = { onCancel(req.id) })
RequestRow(
req = req,
onCancel = { onCancel(req.id) },
onListen = req.listenRoute()?.let { route -> { onListen(route) } },
)
HorizontalDivider()
}
}
}
@Composable
private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
private fun RequestRow(
req: RequestRef,
onCancel: () -> Unit,
onListen: (() -> Unit)?,
) {
var showCancelConfirm by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -108,6 +135,7 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KindAvatar(kind = req.kind)
Column(modifier = Modifier.weight(1f)) {
Text(
text = req.displayName,
@@ -123,6 +151,15 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
StatusPill(status = req.status)
KindPill(label = req.kind)
}
val progress = req.ingestProgressText()
if (progress != null) {
Text(
text = progress,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) {
Text(
text = req.notes,
@@ -134,13 +171,91 @@ private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
)
}
}
if (req.status == RequestStatus.PENDING) {
TextButton(onClick = onCancel) {
when {
onListen != null -> OutlinedButton(onClick = onListen) {
Icon(
Lucide.Play,
contentDescription = null,
modifier = Modifier.size(16.dp).padding(end = 4.dp),
)
Text("Listen")
}
req.status == RequestStatus.PENDING -> TextButton(onClick = { showCancelConfirm = true }) {
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Cancel")
}
}
}
if (showCancelConfirm) {
AlertDialog(
onDismissRequest = { showCancelConfirm = false },
title = { Text("Cancel request?") },
text = { Text("Cancel “${req.displayName}”? Lidarr will stop searching for it.") },
confirmButton = {
Button(
onClick = {
showCancelConfirm = false
onCancel()
},
) { Text("Cancel request") }
},
dismissButton = {
TextButton(onClick = { showCancelConfirm = false }) { Text("Keep") }
},
)
}
}
@Composable
private fun KindAvatar(kind: String) {
// Flutter mapping: disc-3 for artist, library-big for album,
// music for track. Mirrors lib/requests/requests_screen.dart.
val icon = when (kind) {
"artist" -> Lucide.Disc3
"album" -> Lucide.LibraryBig
"track" -> Lucide.Music
else -> Lucide.Music
}
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
}
/**
* Most-specific listenable detail-screen route for a completed
* request, or null when nothing's been matched / the status isn't
* completed. Track-level matches resolve to AlbumDetail since the
* client has no TrackDetail screen.
*/
private fun RequestRef.listenRoute(): Any? {
if (status != RequestStatus.COMPLETED) return null
matchedAlbumId?.takeIf { it.isNotEmpty() }?.let { return AlbumDetail(it) }
matchedArtistId?.takeIf { it.isNotEmpty() }?.let { return ArtistDetail(it) }
return null
}
/**
* "2 albums · 14 tracks ingested" — null when both counts are zero.
* Singularization matches Flutter ("1 album · 1 track ingested").
*/
private fun RequestRef.ingestProgressText(): String? {
if (importedAlbumCount == 0 && importedTrackCount == 0) return null
val parts = buildList {
if (importedAlbumCount > 0) {
add(
"$importedAlbumCount ${if (importedAlbumCount == 1) "album" else "albums"}",
)
}
if (importedTrackCount > 0) {
add(
"$importedTrackCount ${if (importedTrackCount == 1) "track" else "tracks"}",
)
}
}
return "${parts.joinToString(" · ")} ingested"
}
@Composable