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
@@ -47,6 +47,8 @@ class SettingsScreen extends ConsumerWidget {
children: const [
_ProfileSection(),
_Divider(),
_RequestsSection(),
_Divider(),
_AppearanceSection(),
_Divider(),
StorageSection(),
@@ -94,6 +96,33 @@ class _SectionHeader extends StatelessWidget {
}
}
class _RequestsSection extends StatelessWidget {
const _RequestsSection();
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return ListTile(
key: const Key('settings_requests_card'),
leading: Icon(Icons.queue_music, color: fs.parchment),
title: Text(
'My requests',
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 18,
),
),
subtitle: Text(
"Track what you've asked Minstrel to add",
style: TextStyle(color: fs.ash),
),
trailing: Icon(Icons.chevron_right, color: fs.ash),
onTap: () => context.push('/requests'),
);
}
}
/// Renders an "Admin" entry only when the current profile has
/// `is_admin = true`. Returns an empty SizedBox otherwise so the
/// const-children layout above stays valid.