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:
+38
@@ -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(),
|
||||
)
|
||||
+70
-47
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user