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:
+51
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user