feat(android): Phase 10 Commit B — Requests screen (your requests + cancel)

Replaces the Requests-route ComingSoon stub with the real screen.
Mirrors `flutter_client/lib/requests/requests_screen.dart`.

New:
  - models/wire/RequestWire.kt — @Serializable matching
    `internal/api/requests.go requestView`. Used by both
    /api/requests and /api/admin/requests (admin reuse comes in
    Commit D).
  - models/Request.kt — RequestRef domain + RequestStatus enum
    (PENDING / APPROVED / REJECTED / COMPLETED / FAILED / UNKNOWN).
    `displayName` picks the kind-appropriate field (albumTitle for
    album-kind etc).
  - api/endpoints/RequestsApi.kt — Retrofit: GET /api/requests + DELETE
    /api/requests/{id}. The cancel response returns the cancelled
    row (not 204), so the wire return type is RequestWire.
  - requests/data/RequestsRepository.kt — listMine + cancel with
    internal wire→domain mapper. No Room cache — same rationale as
    HistoryRepository.
  - requests/ui/RequestsViewModel.kt — VM + UiState in a sibling file
    (preempts the TooManyFunctions cap that Discover hit).
    `cancel(id)` is optimistic: drops the row from local state
    immediately, refetches on success (or rolls back via refetch on
    failure).
  - requests/ui/RequestsScreen.kt — composable: title bar with back
    button + MainAppBarActions, LazyColumn of RequestRow tiles
    (display name + status/kind pills + Cancel button visible only
    on PENDING). Rejected requests surface the server-provided notes
    line.

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

Hidden tab (quarantine) + admin slices come in Commits C and D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 00:33:56 -04:00
parent 43d436573c
commit 2b4226ee6a
7 changed files with 459 additions and 1 deletions
@@ -0,0 +1,25 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RequestWire
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Retrofit interface for the user-side `/api/requests`. Mirrors
* `flutter_client/lib/api/endpoints/requests.dart`.
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
* a separate `/api/admin/requests` route (Phase 10 Commit D).
*
* The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch.
*/
interface RequestsApi {
@GET("api/requests")
suspend fun listMine(): List<RequestWire>
@DELETE("api/requests/{id}")
suspend fun cancel(@Path("id") id: String): RequestWire
}
@@ -0,0 +1,64 @@
package com.fabledsword.minstrel.models
/**
* Status of a Lidarr request. Stored on the wire as a plain string;
* the typed enum keeps switch / when blocks exhaustive and lets the
* UI map directly to a status pill color without raw-string matches.
*/
enum class RequestStatus {
PENDING, APPROVED, REJECTED, COMPLETED, FAILED, UNKNOWN;
companion object {
fun fromWire(s: String): RequestStatus = when (s) {
"pending" -> PENDING
"approved" -> APPROVED
"rejected" -> REJECTED
"completed" -> COMPLETED
"failed" -> FAILED
else -> UNKNOWN
}
}
val wire: String get() = name.lowercase()
}
/**
* One Lidarr request the user has submitted. Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
* shared between the user-side `/api/requests` view and the admin
* cross-user view since the wire shape is identical.
*
* `displayName` picks the right field based on kind:
* - album → albumTitle (fallback artistName)
* - track → trackTitle (fallback artistName)
* - artist → artistName
*
* `listenHrefId` returns the most-specific matched library ID once the
* request completes (track → album → artist) so the "Listen" CTA can
* navigate to the right detail screen. Returns null until the
* server's ingest job populates the matched_* fields.
*/
data class RequestRef(
val id: String,
val userId: String,
val status: RequestStatus,
val kind: String,
val artistName: String,
val albumTitle: String? = null,
val trackTitle: String? = null,
val requestedAt: String,
val decidedAt: String? = null,
val notes: String? = null,
val importedAlbumCount: Int = 0,
val importedTrackCount: Int = 0,
val matchedTrackId: String? = null,
val matchedAlbumId: String? = null,
val matchedArtistId: String? = null,
) {
val displayName: String
get() = when (kind) {
"album" -> albumTitle ?: artistName
"track" -> trackTitle ?: artistName
else -> artistName
}
}
@@ -0,0 +1,36 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape of `requestView` from `internal/api/requests.go` — the
* row returned by both `GET /api/requests` (caller's own requests) and
* `GET /api/admin/requests` (cross-user admin view). Mirrors
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
*
* Status values: `pending` / `approved` / `rejected` / `completed` /
* `failed`. Kind values: `artist` / `album` / `track`.
*
* `matched_*_id` are set after a completed request ingests into the
* local library; the user-side "Listen" affordance picks the most
* specific one (track → album → artist).
*/
@Serializable
data class RequestWire(
val id: String,
@SerialName("user_id") val userId: String = "",
val status: String = "pending",
val kind: String = "artist",
@SerialName("artist_name") val artistName: String = "",
@SerialName("album_title") val albumTitle: String? = null,
@SerialName("track_title") val trackTitle: String? = null,
@SerialName("requested_at") val requestedAt: String = "",
@SerialName("decided_at") val decidedAt: String? = null,
val notes: String? = null,
@SerialName("imported_album_count") val importedAlbumCount: Int = 0,
@SerialName("imported_track_count") val importedTrackCount: Int = 0,
@SerialName("matched_track_id") val matchedTrackId: String? = null,
@SerialName("matched_album_id") val matchedAlbumId: String? = null,
@SerialName("matched_artist_id") val matchedArtistId: String? = null,
)
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.library.ui.LibraryScreen
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
import com.fabledsword.minstrel.requests.ui.RequestsScreen
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
@@ -85,7 +86,7 @@ private fun NavGraphBuilder.inShellTopLevel(
}
composable<Requests> {
ShellScaffold(onExpandPlayer = expandPlayer) {
ComingSoon("Requests", "Your Lidarr requests — Phase 10.")
RequestsScreen(navController = navController)
}
}
}
@@ -0,0 +1,56 @@
package com.fabledsword.minstrel.requests.data
import com.fabledsword.minstrel.api.endpoints.RequestsApi
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 caller's own Lidarr requests +
* cancel-while-pending write. No Room caching for v1 — same rationale
* as HistoryRepository; the offline-snapshot mirror lands with
* Phase 13. Cancel + the create path (DiscoverRepository) both fire
* direct REST today; the create path enqueues on failure via the
* MutationQueue, cancel does not (it's a no-op if the request is
* already gone).
*/
@Singleton
class RequestsRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: RequestsApi = retrofit.create()
suspend fun listMine(): List<RequestRef> =
api.listMine().map { it.toDomain() }
/**
* Cancels a pending request. Server returns the cancelled row
* (now status=rejected with cancel notes) so callers can refresh
* local state from the return value without a list refetch.
*/
suspend fun cancel(id: String): RequestRef = api.cancel(id).toDomain()
}
// ── Mappers (internal — wire stays out of UI) ──
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,180 @@
package com.fabledsword.minstrel.requests.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.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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.composables.icons.lucide.X
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.models.RequestStatus
import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequestsScreen(
navController: NavHostController,
viewModel: RequestsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text("Your requests") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(Lucide.ArrowLeft, contentDescription = "Back")
}
},
actions = {
MainAppBarActions(
navController = navController,
currentRouteName = Requests::class.qualifiedName,
)
},
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
when (val s = state) {
RequestsUiState.Loading -> LoadingCentered()
RequestsUiState.Empty -> EmptyState(
title = "Nothing requested yet",
body = "Use Discover to ask Lidarr for new music; your " +
"requests show up here.",
)
is RequestsUiState.Error -> EmptyState(
title = "Couldn't load requests",
body = s.message,
)
is RequestsUiState.Success -> RequestList(
rows = s.requests,
onCancel = viewModel::cancel,
)
}
}
}
}
@Composable
private fun RequestList(rows: List<RequestRef>, onCancel: (String) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { req ->
RequestRow(req = req, onCancel = { onCancel(req.id) })
HorizontalDivider()
}
}
}
@Composable
private fun RequestRow(req: RequestRef, onCancel: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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)
}
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,
)
}
}
if (req.status == RequestStatus.PENDING) {
TextButton(onClick = onCancel) {
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
Text("Cancel")
}
}
}
}
@Composable
private fun StatusPill(status: RequestStatus) {
val (label, isError) = when (status) {
RequestStatus.PENDING -> "pending" to false
RequestStatus.APPROVED -> "approved" to false
RequestStatus.COMPLETED -> "completed" to false
RequestStatus.REJECTED -> "rejected" to true
RequestStatus.FAILED -> "failed" to true
RequestStatus.UNKNOWN -> "unknown" to false
}
val color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
AssistChip(
onClick = {},
enabled = false,
label = { Text(label, color = color) },
)
}
@Composable
private fun KindPill(label: String) {
AssistChip(
onClick = {},
enabled = false,
label = {
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
@Composable
private fun LoadingCentered() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
@@ -0,0 +1,96 @@
package com.fabledsword.minstrel.requests.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.models.RequestRef
import com.fabledsword.minstrel.requests.data.RequestsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RequestsUiState {
data object Loading : RequestsUiState
data object Empty : RequestsUiState
data class Success(val requests: List<RequestRef>) : RequestsUiState
data class Error(val message: String) : RequestsUiState
}
@HiltViewModel
class RequestsViewModel @Inject constructor(
private val repository: RequestsRepository,
) : ViewModel() {
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
val uiState: StateFlow<RequestsUiState> = internal.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
internal.value = RequestsUiState.Loading
try {
val rows = repository.listMine()
internal.value = if (rows.isEmpty()) {
RequestsUiState.Empty
} else {
RequestsUiState.Success(rows)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
}
}
}
/**
* Optimistically removes [id] from the success list so the row
* disappears immediately; on server-side success the row is
* replaced by the canonical cancelled state from the return value.
* On error rolls back by refetching the list.
*/
fun cancel(id: String) {
val before = internal.value
if (before is RequestsUiState.Success) {
val filtered = before.requests.filterNot { it.id == id }
internal.value = if (filtered.isEmpty()) {
RequestsUiState.Empty
} else {
RequestsUiState.Success(filtered)
}
}
viewModelScope.launch {
try {
val updated = repository.cancel(id)
internal.update { state ->
if (state !is RequestsUiState.Success) {
// Mid-refresh — let the refresh result win, but
// splice the cancelled row in so it shows the new
// status if the user re-pulls.
RequestsUiState.Success(listOf(updated))
} else {
// Re-insert the cancelled row in its old position
// would require remembering the prior ordering;
// simpler to refetch in the background.
state
}
}
refresh()
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Roll back by re-fetching; the failed cancel might
// already be visible to the server (eventual consistency)
// or not at all (transport drop). The refetch makes
// local state match either outcome.
refresh()
}
}
}
}