From 49a5324be81bec8332d84ec41215ba44992374b3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 12:52:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Requests=20polish=20=E2=80=94?= =?UTF-8?q?=20Listen=20/=20cancel=20confirm=20/=20progress=20/=20kind=20av?= =?UTF-8?q?atar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ''? 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) --- .../minstrel/requests/ui/RequestsScreen.kt | 125 +++++++++++++++++- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt index 37e64400..fe6f61ab 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -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, onCancel: (String) -> Unit) { +private fun RequestList( + rows: List, + 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