From 2a55e6a64713a9ed55bc6005790243ec8a3b4e5f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 01:16:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2010=20Commit=20D1=20?= =?UTF-8?q?=E2=80=94=20Admin=20Requests=20screen=20(approve/reject)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../admin/data/AdminRequestsRepository.kt | 51 ++++++ .../minstrel/admin/ui/AdminRequestsScreen.kt | 157 ++++++++++++++++++ .../admin/ui/AdminRequestsViewModel.kt | 85 ++++++++++ .../api/endpoints/AdminRequestsApi.kt | 25 +++ .../minstrel/nav/MinstrelNavGraph.kt | 3 +- 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt new file mode 100644 index 00000000..18587b41 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminRequestsRepository.kt @@ -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 = 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt new file mode 100644 index 00000000..627cc197 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsScreen.kt @@ -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, + 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.kt new file mode 100644 index 00000000..b860d440 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminRequestsViewModel.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.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) : AdminRequestsUiState + data class Error(val message: String) : AdminRequestsUiState +} + +@HiltViewModel +class AdminRequestsViewModel @Inject constructor( + private val repository: AdminRequestsRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(AdminRequestsUiState.Loading) + val uiState: StateFlow = 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() + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt new file mode 100644 index 00000000..ef5b86cd --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminRequestsApi.kt @@ -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 + + @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) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index ea1d9754..bc64eeef 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Admin · Requests", "Lands with admin slice.") + AdminRequestsScreen(navController = navController) } } composable {