Files
minstrel/flutter_client/lib/models/admin_request.dart
T
bvandeusen 0db7054518 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>
2026-05-10 18:08:23 -04:00

78 lines
2.7 KiB
Dart

/// Mirrors `requestView` from internal/api/requests.go. Server returns
/// these as a flat list (no envelope) from GET /api/admin/requests.
///
/// Note the requester is exposed only as a UUID (`user_id`) — the
/// response does not include the username. The Flutter Requests screen
/// joins client-side against the AdminUser list for display.
class AdminRequest {
const AdminRequest({
required this.id,
required this.userId,
required this.status,
required this.kind,
required this.artistName,
this.albumTitle,
this.trackTitle,
required this.requestedAt,
this.decidedAt,
this.notes,
required this.importedAlbumCount,
required this.importedTrackCount,
this.matchedTrackId,
this.matchedAlbumId,
this.matchedArtistId,
});
final String id;
final String userId;
/// One of: pending, approved, rejected, completed, failed.
final String status;
/// One of: artist, album, track.
final String kind;
final String artistName;
final String? albumTitle;
final String? trackTitle;
final String requestedAt;
final String? decidedAt;
final String? notes;
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.
String get displayName => switch (kind) {
'album' => albumTitle ?? artistName,
'track' => trackTitle ?? artistName,
_ => artistName,
};
factory AdminRequest.fromJson(Map<String, dynamic> j) => AdminRequest(
id: j['id'] as String? ?? '',
userId: j['user_id'] as String? ?? '',
status: j['status'] as String? ?? 'pending',
kind: j['kind'] as String? ?? 'artist',
artistName: j['artist_name'] as String? ?? '',
albumTitle: j['album_title'] as String?,
trackTitle: j['track_title'] as String?,
requestedAt: j['requested_at'] as String? ?? '',
decidedAt: j['decided_at'] as String?,
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?,
);
}