diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt new file mode 100644 index 00000000..cc9ed2db --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RequestsApi.kt @@ -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 + + @DELETE("api/requests/{id}") + suspend fun cancel(@Path("id") id: String): RequestWire +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt new file mode 100644 index 00000000..45b075d6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/Request.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt new file mode 100644 index 00000000..faa4973c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RequestWire.kt @@ -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, +) 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 1a5888f5..ea1d9754 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 @@ -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 { ShellScaffold(onExpandPlayer = expandPlayer) { - ComingSoon("Requests", "Your Lidarr requests — Phase 10.") + RequestsScreen(navController = navController) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt new file mode 100644 index 00000000..e2e87f4f --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/data/RequestsRepository.kt @@ -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 = + 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt new file mode 100644 index 00000000..8246fdfa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsScreen.kt @@ -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, 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) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt new file mode 100644 index 00000000..22a35370 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/requests/ui/RequestsViewModel.kt @@ -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) : RequestsUiState + data class Error(val message: String) : RequestsUiState +} + +@HiltViewModel +class RequestsViewModel @Inject constructor( + private val repository: RequestsRepository, +) : ViewModel() { + + private val internal = MutableStateFlow(RequestsUiState.Loading) + val uiState: StateFlow = 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() + } + } + } +}