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