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
@@ -0,0 +1,31 @@
import 'package:dio/dio.dart';
import '../../models/admin_request.dart';
/// User-side requests API — `/api/requests`. Server scopes results to
/// the caller; admins see only their own here, not all users'. The
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
///
/// Wire shape is identical to the admin endpoint, so the same
/// AdminRequest model is reused.
class RequestsApi {
RequestsApi(this._dio);
final Dio _dio;
/// GET /api/requests — caller's own requests, all statuses.
Future<List<AdminRequest>> listMine() async {
final r = await _dio.get<List<dynamic>>('/api/requests');
final raw = r.data ?? const [];
return raw
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// DELETE /api/requests/{id} — cancel a pending request. Server
/// returns the cancelled row body (not 204) so the caller can patch
/// local state without a refetch.
Future<AdminRequest> cancel(String id) async {
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
return AdminRequest.fromJson(r.data ?? const {});
}
}
@@ -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?,
);
}
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/requests.dart';
import '../library/library_providers.dart' show dioProvider;
import '../models/admin_request.dart';
final requestsApiProvider = FutureProvider<RequestsApi>((ref) async {
return RequestsApi(await ref.watch(dioProvider.future));
});
/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh
/// every 12s while any row is mid-ingest (status='approved'), stop when
/// all rows settle. Polls regardless of app foreground/background — a
/// future enhancement could pause via WidgetsBindingObserver, but for
/// v1 the small extra refresh is acceptable.
class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
static const Duration _pollInterval = Duration(seconds: 12);
Timer? _pollTimer;
@override
Future<List<AdminRequest>> build() async {
ref.onDispose(() {
_pollTimer?.cancel();
_pollTimer = null;
});
final api = await ref.watch(requestsApiProvider.future);
final rows = await api.listMine();
_maybeStartPolling(rows);
return rows;
}
void _maybeStartPolling(List<AdminRequest> rows) {
_pollTimer?.cancel();
if (rows.any((r) => r.status == 'approved')) {
_pollTimer = Timer.periodic(_pollInterval, (_) {
ref.invalidateSelf();
});
}
}
/// Optimistic remove + REST cancel. Restores the row on failure so
/// the user can retry — silent failure would lie about success.
Future<void> cancel(String id) async {
final api = await ref.read(requestsApiProvider.future);
final current = state.value ?? const <AdminRequest>[];
state = AsyncData(current.where((r) => r.id != id).toList());
try {
await api.cancel(id);
} catch (e, st) {
state = AsyncData(current);
Error.throwWithStackTrace(e, st);
}
}
}
final myRequestsProvider =
AsyncNotifierProvider<MyRequestsController, List<AdminRequest>>(
MyRequestsController.new);
@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../models/admin_request.dart';
import '../shared/widgets/main_app_bar_actions.dart';
import '../theme/theme_extension.dart';
import 'requests_provider.dart';
class RequestsScreen extends ConsumerWidget {
const RequestsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final requests = ref.watch(myRequestsProvider);
return Scaffold(
backgroundColor: fs.obsidian,
appBar: AppBar(
backgroundColor: fs.obsidian,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: fs.parchment),
onPressed: () => context.pop(),
),
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
actions: const [MainAppBarActions(currentRoute: '/requests')],
),
body: SafeArea(
child: requests.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('$e', style: TextStyle(color: fs.error)),
),
),
data: (rows) {
if (rows.isEmpty) {
return Center(
child: Text('Nothing requested yet.',
style: TextStyle(color: fs.ash)),
);
}
final notifier = ref.read(myRequestsProvider.notifier);
return RefreshIndicator(
onRefresh: () async => ref.refresh(myRequestsProvider.future),
child: ListView.separated(
itemCount: rows.length,
padding: const EdgeInsets.symmetric(vertical: 8),
separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1),
itemBuilder: (_, i) => _RequestRow(
key: Key('request_row_${rows[i].id}'),
request: rows[i],
onCancel: () => notifier.cancel(rows[i].id),
),
),
);
},
),
),
);
}
}
class _RequestRow extends StatelessWidget {
const _RequestRow({
super.key,
required this.request,
required this.onCancel,
});
final AdminRequest request;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final href = _listenHref(request);
return ListTile(
leading: _kindAvatar(fs),
title: Text(
request.displayName,
style: TextStyle(
color: fs.parchment,
fontFamily: 'Fraunces',
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
_KindPill(label: request.kind),
const SizedBox(width: 6),
_StatusPill(status: request.status),
],
),
if (request.importedAlbumCount > 0 ||
request.importedTrackCount > 0) ...[
const SizedBox(height: 4),
Text(
_ingestProgressText(request),
style: TextStyle(color: fs.accent, fontSize: 13),
),
],
if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[
const SizedBox(height: 4),
Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)),
],
],
),
trailing: _trailing(context, fs, href),
);
}
Widget _kindAvatar(FabledSwordTheme fs) {
final icon = switch (request.kind) {
'artist' => Icons.album,
'album' => Icons.library_music,
_ => Icons.music_note,
};
return CircleAvatar(
backgroundColor: fs.iron,
child: Icon(icon, size: 20, color: fs.ash),
);
}
Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) {
if (request.status == 'pending') {
return TextButton.icon(
onPressed: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Cancel request?'),
content: Text('Cancel "${request.displayName}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Keep'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
child: const Text('Cancel request'),
),
],
),
);
if (ok == true) onCancel();
},
icon: const Icon(Icons.close, size: 16),
label: const Text('Cancel'),
style: TextButton.styleFrom(foregroundColor: fs.ash),
);
}
if (request.status == 'completed' && href != null) {
return TextButton.icon(
onPressed: () => context.push(href),
icon: const Icon(Icons.play_arrow, size: 16),
label: const Text('Listen'),
style: TextButton.styleFrom(foregroundColor: fs.accent),
);
}
return null;
}
String _ingestProgressText(AdminRequest r) {
if (r.kind == 'artist') {
final albums = '${r.importedAlbumCount} '
'${r.importedAlbumCount == 1 ? 'album' : 'albums'}';
final tracks = '${r.importedTrackCount} '
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
return '$albums · $tracks';
}
if (r.kind == 'album') {
return '${r.importedTrackCount} '
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
}
return 'Track ingested';
}
String? _listenHref(AdminRequest r) {
if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}';
if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}';
if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}';
return null;
}
}
class _KindPill extends StatelessWidget {
const _KindPill({required this.label});
final String label;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: fs.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: TextStyle(color: fs.accent, fontSize: 11),
),
);
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final color = switch (status) {
'pending' => fs.ash,
'approved' => fs.bronze,
'completed' => fs.moss,
'rejected' => fs.oxblood,
'failed' => fs.oxblood,
_ => fs.ash,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(999),
),
child: Text(
status,
style: TextStyle(color: color, fontSize: 11),
),
);
}
}
@@ -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.
+2
View File
@@ -15,6 +15,7 @@ import '../player/player_bar.dart';
import '../player/queue_screen.dart';
import '../playlists/playlist_detail_screen.dart';
import '../playlists/playlists_list_screen.dart';
import '../requests/requests_screen.dart';
import '../search/search_screen.dart';
import '../settings/settings_screen.dart';
import '../admin/admin_landing_screen.dart';
@@ -75,6 +76,7 @@ GoRouter buildRouter(Ref ref) {
path: '/playlists/:id',
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
),
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:minstrel/auth/auth_provider.dart';
import 'package:minstrel/models/admin_request.dart';
import 'package:minstrel/models/user.dart';
import 'package:minstrel/requests/requests_provider.dart';
import 'package:minstrel/requests/requests_screen.dart';
import 'package:minstrel/theme/theme_data.dart';
class _StubAuth extends AuthController {
@override
Future<User?> build() async =>
const User(id: 'u1', username: 'alice', isAdmin: false);
}
class _StubRequests extends MyRequestsController {
_StubRequests(this._initial);
final List<AdminRequest> _initial;
@override
Future<List<AdminRequest>> build() async => _initial;
}
const _pendingAlbum = AdminRequest(
id: 'r1',
userId: 'u1',
status: 'pending',
kind: 'album',
artistName: 'Aphex Twin',
albumTitle: 'Drukqs',
trackTitle: null,
requestedAt: '2026-05-08T00:00:00Z',
decidedAt: null,
notes: null,
importedAlbumCount: 0,
importedTrackCount: 0,
);
const _completedTrackWithMatch = AdminRequest(
id: 'r2',
userId: 'u1',
status: 'completed',
kind: 'track',
artistName: 'Boards of Canada',
albumTitle: 'Geogaddi',
trackTitle: 'Roygbiv',
requestedAt: '2026-05-01T00:00:00Z',
decidedAt: '2026-05-02T00:00:00Z',
notes: null,
importedAlbumCount: 1,
importedTrackCount: 1,
matchedTrackId: 't1',
matchedAlbumId: 'a1',
);
const _rejectedWithNotes = AdminRequest(
id: 'r3',
userId: 'u1',
status: 'rejected',
kind: 'artist',
artistName: 'Some Artist',
albumTitle: null,
trackTitle: null,
requestedAt: '2026-05-01T00:00:00Z',
decidedAt: '2026-05-02T00:00:00Z',
notes: 'Not in MusicBrainz',
importedAlbumCount: 0,
importedTrackCount: 0,
);
Widget _harness(List<AdminRequest> requests) => ProviderScope(
overrides: [
authControllerProvider.overrideWith(() => _StubAuth()),
myRequestsProvider.overrideWith(() => _StubRequests(requests)),
],
child: MaterialApp(
theme: buildThemeData(),
home: const RequestsScreen(),
),
);
void main() {
testWidgets('renders empty state when no requests', (t) async {
await t.pumpWidget(_harness(const []));
await t.pumpAndSettle();
expect(find.text('Nothing requested yet.'), findsOneWidget);
});
testWidgets('renders pending row with Cancel CTA', (t) async {
await t.pumpWidget(_harness(const [_pendingAlbum]));
await t.pumpAndSettle();
expect(find.byKey(const Key('request_row_r1')), findsOneWidget);
expect(find.text('Drukqs'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
});
testWidgets('renders completed row with Listen CTA', (t) async {
await t.pumpWidget(_harness(const [_completedTrackWithMatch]));
await t.pumpAndSettle();
expect(find.text('Roygbiv'), findsOneWidget);
expect(find.text('Listen'), findsOneWidget);
// Ingest progress copy
expect(find.text('Track ingested'), findsOneWidget);
});
testWidgets('renders rejected row with notes; no CTA', (t) async {
await t.pumpWidget(_harness(const [_rejectedWithNotes]));
await t.pumpAndSettle();
expect(find.text('Some Artist'), findsOneWidget);
expect(find.text('Not in MusicBrainz'), findsOneWidget);
expect(find.text('Cancel'), findsNothing);
expect(find.text('Listen'), findsNothing);
});
testWidgets('Cancel button opens confirm dialog', (t) async {
await t.pumpWidget(_harness(const [_pendingAlbum]));
await t.pumpAndSettle();
await t.tap(find.text('Cancel'));
await t.pumpAndSettle();
expect(find.text('Cancel request?'), findsOneWidget);
expect(find.text('Cancel "Drukqs"?'), findsOneWidget);
// Dismiss with Keep
await t.tap(find.text('Keep'));
await t.pumpAndSettle();
expect(find.text('Cancel request?'), findsNothing);
});
}