diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt new file mode 100644 index 00000000..761bcf83 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminInvitesRepository.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt new file mode 100644 index 00000000..7f43f3c4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminInvitesViewModel.kt @@ -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 = emptyList(), + val message: String? = null, +) + +@HiltViewModel +class AdminInvitesViewModel @Inject constructor( + private val repository: AdminInvitesRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminInvitesUiState()) + val state: StateFlow = internal.asStateFlow() + + /** One-shot signal for screens to show the generated token + copy UI. */ + private val createdChannel = Channel(Channel.BUFFERED) + val created: Flow = 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() } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt index 3198712c..e672cbec 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminUsersScreen.kt @@ -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(null) } var deleteCandidate by remember { mutableStateOf(null) } + var showGenerateInvite by remember { mutableStateOf(false) } + var generatedInvite by remember { mutableStateOf(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, - 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) - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt new file mode 100644 index 00000000..04bd383c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminInvitesApi.kt @@ -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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt new file mode 100644 index 00000000..c7eadab2 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Invite.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt new file mode 100644 index 00000000..fbbb302e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/InviteWire.kt @@ -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 = emptyList(), +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt index 838ca200..eda751e0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/ui/PlaylistDetailScreen.kt @@ -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") } } } 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 fe6f61ab..9f6fde7f 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 @@ -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 } /**