feat(android) + fix detekt: AdminUsers Invites + Requests/PlaylistDetail refactors

Two slices in one push because the detekt failures from the previous
slices block landing the Invites work cleanly:

Audit v2 #19 — Admin Users Invites section:
* New domain Invite model + InviteWire envelope.
* AdminInvitesApi (list / create / revoke).
* AdminInvitesRepository.
* AdminInvitesViewModel with optimistic add/remove + one-shot
  Channel for the generated-token dialog.
* AdminUsersScreen restructured: single LazyColumn with two sections
  (Users / Invites). Generate button in Invites header opens a
  dialog for the optional note; after creation a second dialog
  surfaces the token with a Copy-to-clipboard button.

Detekt fixes on already-pushed code:
* PlaylistDetailViewModel.regenerate: 3 returns → single early-bail
  via combined condition.
* PlaylistDetailScreen function (63/60): extracted
  PlaylistDetailContent helper for the when block.
* PlaylistHeader function (65/60): extracted PlaylistHeaderActions.
* RequestsScreen RequestRow (79/60): extracted RequestRowBody +
  RequestRowAction + CancelConfirmDialog helpers.
* RequestRef.listenRoute: 4 returns → single when expression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:01:13 -04:00
parent 7500d283a8
commit c2c9de26e7
8 changed files with 628 additions and 162 deletions
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminInvitesApi
import com.fabledsword.minstrel.api.endpoints.CreateInviteBody
import com.fabledsword.minstrel.models.Invite
import com.fabledsword.minstrel.models.wire.InviteWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin facade over `/api/admin/invites`. Same shape as the other
* admin repos — direct REST, no caching.
*/
@Singleton
class AdminInvitesRepository @Inject constructor(retrofit: Retrofit) {
private val api: AdminInvitesApi = retrofit.create()
suspend fun list(): List<Invite> = api.list().invites.map { it.toDomain() }
suspend fun create(note: String?): Invite =
api.create(CreateInviteBody(note = note?.takeIf { it.isNotEmpty() })).toDomain()
suspend fun revoke(token: String) {
api.revoke(token)
}
}
private fun InviteWire.toDomain(): Invite = Invite(
token = token,
invitedBy = invitedBy,
note = note,
createdAt = createdAt,
expiresAt = expiresAt,
redeemedAt = redeemedAt,
redeemedBy = redeemedBy,
)
@@ -0,0 +1,85 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
import com.fabledsword.minstrel.models.Invite
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class AdminInvitesUiState(
val isLoading: Boolean = true,
val invites: List<Invite> = emptyList(),
val message: String? = null,
)
@HiltViewModel
class AdminInvitesViewModel @Inject constructor(
private val repository: AdminInvitesRepository,
) : ViewModel() {
private val internal = MutableStateFlow(AdminInvitesUiState())
val state: StateFlow<AdminInvitesUiState> = internal.asStateFlow()
/** One-shot signal for screens to show the generated token + copy UI. */
private val createdChannel = Channel<Invite>(Channel.BUFFERED)
val created: Flow<Invite> = createdChannel.receiveAsFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.update { it.copy(isLoading = true, message = null) }
try {
val rows = repository.list()
internal.update { it.copy(isLoading = false, invites = rows) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isLoading = false,
message = "Couldn't load invites: ${e.message ?: "unknown error"}",
)
}
}
}
}
fun generate(note: String?) {
viewModelScope.launch {
try {
val invite = repository.create(note)
createdChannel.trySend(invite)
// Optimistically prepend; next refresh reconciles.
internal.update { it.copy(invites = listOf(invite) + it.invites) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(message = "Couldn't generate invite: ${e.message ?: "unknown error"}")
}
}
}
}
fun revoke(token: String) {
val before = internal.value.invites
// Optimistic removal.
internal.update { it.copy(invites = before.filterNot { i -> i.token == token }) }
viewModelScope.launch {
runCatching { repository.revoke(token) }
.onFailure { refresh() }
}
}
}
@@ -1,7 +1,6 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -9,24 +8,32 @@ 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.Button
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.ArrowLeft
import com.composables.icons.lucide.Copy
import com.composables.icons.lucide.KeyRound
import com.composables.icons.lucide.Plus
import com.composables.icons.lucide.Trash2
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.fabledsword.minstrel.models.Invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -41,7 +48,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminUserRef
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@@ -50,10 +56,19 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
fun AdminUsersScreen(
navController: NavHostController,
viewModel: AdminUsersViewModel = hiltViewModel(),
invitesViewModel: AdminInvitesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val invitesState by invitesViewModel.state.collectAsStateWithLifecycle()
var resetPasswordFor by remember { mutableStateOf<AdminUserRef?>(null) }
var deleteCandidate by remember { mutableStateOf<AdminUserRef?>(null) }
var showGenerateInvite by remember { mutableStateOf(false) }
var generatedInvite by remember { mutableStateOf<Invite?>(null) }
LaunchedEffect(Unit) {
invitesViewModel.created.collect { invite ->
generatedInvite = invite
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
@@ -75,26 +90,41 @@ fun AdminUsersScreen(
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
onRefresh = {
viewModel.refresh().join()
invitesViewModel.refresh()
},
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminUsersUiState.Loading -> LoadingCentered()
AdminUsersUiState.Empty -> EmptyState(
title = "No users",
body = "No registered users found.",
)
is AdminUsersUiState.Error -> EmptyState(
title = "Couldn't load users",
body = s.message,
)
is AdminUsersUiState.Success -> UserList(
users = s.users,
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { SectionHeader("Users") }
usersSection(
state = state,
onSetAdmin = viewModel::setAdmin,
onSetAutoApprove = viewModel::setAutoApprove,
onAskResetPassword = { resetPasswordFor = it },
onAskDelete = { deleteCandidate = it },
)
item { HorizontalDivider() }
item {
SectionHeader(
label = "Invites",
trailing = {
TextButton(onClick = { showGenerateInvite = true }) {
Icon(
Lucide.Plus,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text("Generate")
}
},
)
}
invitesSection(
state = invitesState,
onRevoke = invitesViewModel::revoke,
)
}
}
}
@@ -107,6 +137,208 @@ fun AdminUsersScreen(
onConfirmReset = viewModel::resetPassword,
onConfirmDelete = viewModel::delete,
)
if (showGenerateInvite) {
GenerateInviteDialog(
onDismiss = { showGenerateInvite = false },
onConfirm = { note ->
invitesViewModel.generate(note)
showGenerateInvite = false
},
)
}
generatedInvite?.let { invite ->
GeneratedInviteDialog(
invite = invite,
onDismiss = { generatedInvite = null },
)
}
}
@Composable
private fun SectionHeader(label: String, trailing: (@Composable () -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
if (trailing != null) trailing()
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.usersSection(
state: AdminUsersUiState,
onSetAdmin: (String, Boolean) -> Unit,
onSetAutoApprove: (String, Boolean) -> Unit,
onAskResetPassword: (AdminUserRef) -> Unit,
onAskDelete: (AdminUserRef) -> Unit,
) {
when (state) {
AdminUsersUiState.Loading -> item { CenteredHint("Loading users…") }
AdminUsersUiState.Empty -> item { CenteredHint("No registered users.") }
is AdminUsersUiState.Error -> item { CenteredHint(state.message) }
is AdminUsersUiState.Success -> items(items = state.users, key = { it.id }) { user ->
UserRow(
user = user,
onSetAdmin = { onSetAdmin(user.id, it) },
onSetAutoApprove = { onSetAutoApprove(user.id, it) },
onResetPassword = { onAskResetPassword(user) },
onDelete = { onAskDelete(user) },
)
HorizontalDivider()
}
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.invitesSection(
state: AdminInvitesUiState,
onRevoke: (String) -> Unit,
) {
when {
state.isLoading -> item { CenteredHint("Loading invites…") }
state.invites.isEmpty() -> item {
CenteredHint("No active invites. Use Generate to create one.")
}
else -> items(items = state.invites, key = { it.token }) { invite ->
InviteRow(invite = invite, onRevoke = { onRevoke(invite.token) })
HorizontalDivider()
}
}
}
@Composable
private fun CenteredHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
@Composable
private fun InviteRow(invite: Invite, onRevoke: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = invite.token,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val subtitle = buildString {
if (!invite.note.isNullOrEmpty()) {
append(invite.note)
append(" · ")
}
append(if (invite.isRedeemed) "redeemed" else "expires ${invite.expiresAt}")
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (!invite.isRedeemed) {
IconButton(onClick = onRevoke) {
Icon(
Lucide.Trash2,
contentDescription = "Revoke invite",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
@Composable
private fun GenerateInviteDialog(onDismiss: () -> Unit, onConfirm: (String?) -> Unit) {
var note by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Generate invite") },
text = {
Column {
Text(
text = "Token expires in 24 hours.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("Note (optional)") },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
singleLine = true,
)
}
},
confirmButton = {
Button(onClick = { onConfirm(note.takeIf { it.isNotBlank() }) }) {
Text("Generate")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun GeneratedInviteDialog(invite: Invite, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite created") },
text = {
Column {
Text(
text = "Share this token. It expires in 24 hours.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = invite.token,
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
}
},
confirmButton = {
Button(
onClick = {
clipboard.setText(AnnotatedString(invite.token))
onDismiss()
},
) {
Icon(
Lucide.Copy,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Text(" Copy", modifier = Modifier.padding(start = 4.dp))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Close") }
},
)
}
@Composable
@@ -140,28 +372,6 @@ private fun PendingDialogs(
}
}
@Composable
private fun UserList(
users: List<AdminUserRef>,
onSetAdmin: (String, Boolean) -> Unit,
onSetAutoApprove: (String, Boolean) -> Unit,
onAskResetPassword: (AdminUserRef) -> Unit,
onAskDelete: (AdminUserRef) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = users, key = { it.id }) { user ->
UserRow(
user = user,
onSetAdmin = { onSetAdmin(user.id, it) },
onSetAutoApprove = { onSetAutoApprove(user.id, it) },
onResetPassword = { onAskResetPassword(user) },
onDelete = { onAskDelete(user) },
)
HorizontalDivider()
}
}
}
@Composable
private fun UserRow(
user: AdminUserRef,
@@ -285,9 +495,3 @@ private fun DeleteConfirmDialog(
)
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,36 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminInvitesListWire
import com.fabledsword.minstrel.models.wire.InviteWire
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/invites`. Mirrors
* `flutter_client/lib/api/endpoints/admin_invites.dart`.
*
* Server TTL is hardcoded at 24h; the only configurable field is the
* optional `note` on create.
*/
interface AdminInvitesApi {
@GET("api/admin/invites")
suspend fun list(): AdminInvitesListWire
@POST("api/admin/invites")
suspend fun create(@Body body: CreateInviteBody): InviteWire
@DELETE("api/admin/invites/{token}")
suspend fun revoke(@Path("token") token: String)
}
/**
* Body for `POST /api/admin/invites`. The Flutter client omits the
* field when note is empty; Kotlin/serialization just serializes
* null which the server treats identically.
*/
@Serializable
data class CreateInviteBody(val note: String? = null)
@@ -0,0 +1,22 @@
package com.fabledsword.minstrel.models
/**
* Domain shape for one admin-issued registration invite. Mirrors
* `flutter_client/lib/models/invite.dart Invite` and the server's
* `inviteResp` from `internal/api/admin_invites.go`.
*
* `invitedBy` and `redeemedBy` are UUIDs of users (not usernames);
* the server doesn't denormalise. TTL is hardcoded server-side at
* 24h — admins can only customise the optional `note`.
*/
data class Invite(
val token: String,
val invitedBy: String,
val note: String?,
val createdAt: String,
val expiresAt: String,
val redeemedAt: String? = null,
val redeemedBy: String? = null,
) {
val isRedeemed: Boolean get() = redeemedAt != null
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for one invite row.
* `invited_by`/`redeemed_by` carry user UUIDs.
*/
@Serializable
data class InviteWire(
val token: String = "",
@SerialName("invited_by") val invitedBy: String = "",
val note: String? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("expires_at") val expiresAt: String = "",
@SerialName("redeemed_at") val redeemedAt: String? = null,
@SerialName("redeemed_by") val redeemedBy: String? = null,
)
/**
* Envelope for `GET /api/admin/invites` — `{"invites": [...]}`.
* The single-invite create endpoint returns a bare [InviteWire], so
* the list endpoint is the only place this envelope shows up.
*/
@Serializable
data class AdminInvitesListWire(
val invites: List<InviteWire> = emptyList(),
)
@@ -267,30 +267,45 @@ fun PlaylistDetailScreen(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState()
PlaylistDetailBody(
detail = s.detail,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(s.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(s.detail.tracks.shuffled(), startTrackId = null)
},
onRegenerate = viewModel::regenerate,
onTrackClick = { id ->
viewModel.play(s.detail.tracks, startTrackId = id)
},
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
PlaylistDetailContent(
state = state,
viewModel = viewModel,
playerViewModel = playerViewModel,
navController = navController,
)
}
}
}
@Composable
private fun PlaylistDetailContent(
state: PlaylistDetailUiState,
viewModel: PlaylistDetailViewModel,
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
navController: NavHostController,
) {
when (state) {
PlaylistDetailUiState.Loading -> LoadingCentered()
is PlaylistDetailUiState.Error -> ErrorBlock(state.message, viewModel::refresh)
is PlaylistDetailUiState.Success -> {
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
val playerState by playerViewModel.uiState.collectAsState()
PlaylistDetailBody(
detail = state.detail,
likedTrackIds = likedTrackIds,
playingTrackId = playerState.currentTrack?.id,
onPlayAll = { viewModel.play(state.detail.tracks, startTrackId = null) },
onShuffleAll = {
viewModel.play(state.detail.tracks.shuffled(), startTrackId = null)
},
onRegenerate = viewModel::regenerate,
onTrackClick = { id ->
viewModel.play(state.detail.tracks, startTrackId = id)
},
onToggleTrackLike = viewModel::toggleLikeTrack,
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
)
}
}
}
@@ -389,30 +404,38 @@ private fun PlaylistHeader(
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onPlayAll,
colors = ButtonDefaults.buttonColors(),
) {
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
PlaylistHeaderActions(
refreshable = playlist.refreshable,
onPlayAll = onPlayAll,
onShuffleAll = onShuffleAll,
onRegenerate = onRegenerate,
)
}
}
@Composable
private fun PlaylistHeaderActions(
refreshable: Boolean,
onPlayAll: () -> Unit,
onShuffleAll: () -> Unit,
onRegenerate: () -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onPlayAll, colors = ButtonDefaults.buttonColors()) {
Icon(Lucide.Play, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Play")
}
OutlinedButton(onClick = onShuffleAll) {
Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Shuffle")
}
if (refreshable) {
OutlinedButton(onClick = onRegenerate) {
Icon(Lucide.RefreshCw, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Play")
}
OutlinedButton(onClick = onShuffleAll) {
Icon(Lucide.Shuffle, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Shuffle")
}
if (playlist.refreshable) {
OutlinedButton(onClick = onRegenerate) {
Icon(
Lucide.RefreshCw,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Regenerate")
}
Text("Regenerate")
}
}
}
@@ -136,74 +136,103 @@ private fun RequestRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KindAvatar(kind = req.kind)
Column(modifier = Modifier.weight(1f)) {
Text(
text = req.displayName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
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") }
},
RequestRowBody(req = req, modifier = Modifier.weight(1f))
RequestRowAction(
req = req,
onListen = onListen,
onAskCancel = { showCancelConfirm = true },
)
}
if (showCancelConfirm) {
CancelConfirmDialog(
displayName = req.displayName,
onConfirm = {
showCancelConfirm = false
onCancel()
},
onDismiss = { showCancelConfirm = false },
)
}
}
@Composable
private fun RequestRowBody(req: RequestRef, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = req.displayName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun RequestRowAction(
req: RequestRef,
onListen: (() -> Unit)?,
onAskCancel: () -> Unit,
) {
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 = onAskCancel) {
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Cancel")
}
}
}
@Composable
private fun CancelConfirmDialog(
displayName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Cancel request?") },
text = { Text("Cancel “$displayName”? Lidarr will stop searching for it.") },
confirmButton = {
Button(onClick = onConfirm) { Text("Cancel request") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Keep") }
},
)
}
@Composable
@@ -230,11 +259,11 @@ private fun KindAvatar(kind: String) {
* 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
private fun RequestRef.listenRoute(): Any? = when {
status != RequestStatus.COMPLETED -> null
!matchedAlbumId.isNullOrEmpty() -> AlbumDetail(matchedAlbumId)
!matchedArtistId.isNullOrEmpty() -> ArtistDetail(matchedArtistId)
else -> null
}
/**