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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user