feat(android): Phase 10 Commit D1 — Admin Requests screen (approve/reject)

Replaces the AdminRequests-route ComingSoon stub with the real
admin-side review queue. Mirrors `flutter_client/lib/admin/admin_requests_screen.dart`
+ `AdminRequestsController` from `admin_providers.dart`.

New:
  - api/endpoints/AdminRequestsApi.kt — Retrofit GET
    /api/admin/requests + POST {id}/approve + POST {id}/reject.
    Reuses RequestWire (server returns the same requestView shape as
    /api/requests; only the listing scope differs).
  - admin/data/AdminRequestsRepository.kt — list + approve + reject
    + internal wire→domain mapper. No MutationQueue fallback —
    operator confirmation is point-and-shoot; failures roll back via
    refetch rather than queuing for later replay.
  - admin/ui/AdminRequestsViewModel.kt — VM + UiState (in its own
    file to preempt TooManyFunctions). `decide()` optimistically
    drops the row from local state and refetches on failure so the
    UI matches the server's view.
  - admin/ui/AdminRequestsScreen.kt — Scaffold + TopAppBar with back
    button + MainAppBarActions; LazyColumn of rows showing displayName
    + kind/status pills + Approve/Reject button pair.

Modified:
  - nav/MinstrelNavGraph.kt — AdminRequests route now renders
    `AdminRequestsScreen(navController = navController)` inside
    ShellScaffold.

AdminQuarantine + AdminUsers + AdminLanding still ComingSoon — they
follow in D2/D3. No isAdmin gating on the kebab yet either; until
the AuthController lands (Phase 11), the admin entries are reachable
from any account, which is fine on a single-user dev install but
gets gated before any release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:16:07 -04:00
parent 5108db6ad7
commit 2a55e6a647
5 changed files with 320 additions and 1 deletions
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminRequestsApi
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin cross-user requests queue.
* Mirrors `flutter_client/lib/admin/admin_providers.dart`'s
* AdminRequestsController.
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
* (no mutation queue) because operator confirmation is point-and-shoot;
* if the request fails the UI surfaces the error and rolls back.
*/
@Singleton
class AdminRequestsRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminRequestsApi = retrofit.create()
suspend fun list(): List<RequestRef> = api.list().map { it.toDomain() }
suspend fun approve(id: String) = api.approve(id)
suspend fun reject(id: String) = api.reject(id)
}
private fun RequestWire.toDomain(): RequestRef = RequestRef(
id = id,
userId = userId,
status = RequestStatus.fromWire(status),
kind = kind,
artistName = artistName,
albumTitle = albumTitle,
trackTitle = trackTitle,
requestedAt = requestedAt,
decidedAt = decidedAt,
notes = notes,
importedAlbumCount = importedAlbumCount,
importedTrackCount = importedTrackCount,
matchedTrackId = matchedTrackId,
matchedAlbumId = matchedAlbumId,
matchedArtistId = matchedArtistId,
)
@@ -0,0 +1,157 @@
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
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.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.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.Lucide
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminRequestsScreen(
navController: NavHostController,
viewModel: AdminRequestsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Admin · Requests") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = AdminRequests::class.qualifiedName,
)
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
AdminRequestsUiState.Loading -> LoadingCentered()
AdminRequestsUiState.Empty -> EmptyState(
title = "No requests waiting",
body = "When users ask Lidarr for new music, their pending " +
"requests show up here for approval.",
)
is AdminRequestsUiState.Error -> EmptyState(
title = "Couldn't load requests",
body = s.message,
)
is AdminRequestsUiState.Success -> RequestList(
rows = s.rows,
onApprove = viewModel::approve,
onReject = viewModel::reject,
)
}
}
}
}
@Composable
private fun RequestList(
rows: List<RequestRef>,
onApprove: (String) -> Unit,
onReject: (String) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
AdminRequestRow(req = req, onApprove = { onApprove(req.id) }, onReject = { onReject(req.id) })
HorizontalDivider()
}
}
}
@Composable
private fun AdminRequestRow(
req: RequestRef,
onApprove: () -> Unit,
onReject: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = req.displayName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(req.kind, style = MaterialTheme.typography.labelSmall) },
)
AssistChip(
onClick = {},
enabled = false,
label = { Text(req.status.wire, style = MaterialTheme.typography.labelSmall) },
)
}
if (req.artistName.isNotEmpty() && req.artistName != req.displayName) {
Text(
text = "Artist: ${req.artistName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
OutlinedButton(onClick = onReject) { Text("Reject") }
Button(onClick = onApprove) { Text("Approve") }
}
}
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,85 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.models.RequestRef
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminRequestsUiState {
data object Loading : AdminRequestsUiState
data object Empty : AdminRequestsUiState
data class Success(val rows: List<RequestRef>) : AdminRequestsUiState
data class Error(val message: String) : AdminRequestsUiState
}
@HiltViewModel
class AdminRequestsViewModel @Inject constructor(
private val repository: AdminRequestsRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
val uiState: StateFlow<AdminRequestsUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = AdminRequestsUiState.Loading
try {
val rows = repository.list()
internal.value = if (rows.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminRequestsUiState.Error(
e.message ?: "Admin requests load failed",
)
}
}
}
fun approve(id: String) = decide(id) { repository.approve(it) }
fun reject(id: String) = decide(id) { repository.reject(it) }
/**
* Optimistically removes the decided row from the visible list and
* fires the REST call. On failure, refetches so the UI matches the
* server's view rather than leaving a phantom hole.
*/
private fun decide(id: String, action: suspend (String) -> Unit) {
val before = internal.value
if (before is AdminRequestsUiState.Success) {
val filtered = before.rows.filterNot { it.id == id }
internal.value = if (filtered.isEmpty()) {
AdminRequestsUiState.Empty
} else {
AdminRequestsUiState.Success(filtered)
}
}
viewModelScope.launch {
try {
action(id)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Refetch reconciles whatever the server now says about
// this request; the error is intentionally swallowed
// because the refresh result IS the user-facing signal.
refresh()
}
}
}
}
@@ -0,0 +1,25 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/requests`. Mirrors
* `flutter_client/lib/api/endpoints/admin_requests.dart`.
*
* Server returns the same `requestView` shape as the user-side
* `/api/requests`, so RequestWire is reused. Different listing scope —
* admin sees all users' requests, not just the caller's.
*/
interface AdminRequestsApi {
@GET("api/admin/requests")
suspend fun list(): List<RequestWire>
@POST("api/admin/requests/{id}/approve")
suspend fun approve(@Path("id") id: String)
@POST("api/admin/requests/{id}/reject")
suspend fun reject(@Path("id") id: String)
}
@@ -10,6 +10,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.discover.ui.DiscoverScreen
import com.fabledsword.minstrel.home.ui.HomeScreen
import com.fabledsword.minstrel.library.ui.LibraryScreen
@@ -114,7 +115,7 @@ private fun NavGraphBuilder.inShellDetail(
}
composable<AdminRequests> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Admin · Requests", "Lands with admin slice.")
AdminRequestsScreen(navController = navController)
}
}
composable<AdminQuarantine> {