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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user