feat(flutter): user-facing requests view + cancel (#356)

Closes the cleanest remaining #356 gap — Discover submits Lidarr
requests but had no surface for users to view or cancel their own.
Admin had it; user didn't.

- RequestsApi at lib/api/endpoints/requests.dart — listMine() +
  cancel(id). Same /api/requests endpoint the web /requests page
  uses; identical AdminRequest wire shape so the existing model
  is reused (just augmented with matched_*_id fields for the
  "Listen" CTA on completed rows).
- MyRequestsController mirrors the web's auto-poll (#369 piece
  already shipped server-side / web-side): 12s refresh while any
  row is mid-ingest (status='approved'), stops on settle. Riverpod
  Timer in build() that's cancelled in onDispose.
- RequestsScreen with kind/status pills, ingest progress copy,
  Cancel (with confirm dialog) and Listen CTAs per row state.
- Route /requests under the shell. Entry point in settings between
  Profile and Appearance — "My requests".
- Widget tests cover empty / pending / completed / rejected states +
  the Cancel-confirm dialog.

Out of scope this slice: Discover-screen "View your requests" CTA
after submitting a new request. Reasonable follow-up but doesn't
block the management loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:08:23 -04:00
parent 861dd8ef17
commit 0db7054518
7 changed files with 505 additions and 0 deletions
@@ -18,6 +18,9 @@ class AdminRequest {
this.notes,
required this.importedAlbumCount,
required this.importedTrackCount,
this.matchedTrackId,
this.matchedAlbumId,
this.matchedArtistId,
});
final String id;
@@ -38,6 +41,13 @@ class AdminRequest {
final int importedAlbumCount;
final int importedTrackCount;
/// Set when the ingest matched into the local library. The user-side
/// "Listen" CTA on a completed request links to whichever id is set
/// (most-specific first: track → album → artist).
final String? matchedTrackId;
final String? matchedAlbumId;
final String? matchedArtistId;
/// Display label depending on the kind of request — what the user
/// asked for. For an album request it's the album title; for an
/// artist request it's the artist name; etc.
@@ -60,5 +70,8 @@ class AdminRequest {
notes: j['notes'] as String?,
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
matchedTrackId: j['matched_track_id'] as String?,
matchedAlbumId: j['matched_album_id'] as String?,
matchedArtistId: j['matched_artist_id'] as String?,
);
}