diff --git a/flutter_client/lib/api/endpoints/requests.dart b/flutter_client/lib/api/endpoints/requests.dart new file mode 100644 index 00000000..219668fc --- /dev/null +++ b/flutter_client/lib/api/endpoints/requests.dart @@ -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> listMine() async { + final r = await _dio.get>('/api/requests'); + final raw = r.data ?? const []; + return raw + .map((e) => AdminRequest.fromJson((e as Map).cast())) + .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 cancel(String id) async { + final r = await _dio.delete>('/api/requests/$id'); + return AdminRequest.fromJson(r.data ?? const {}); + } +} diff --git a/flutter_client/lib/models/admin_request.dart b/flutter_client/lib/models/admin_request.dart index da7ba69a..19306d2e 100644 --- a/flutter_client/lib/models/admin_request.dart +++ b/flutter_client/lib/models/admin_request.dart @@ -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?, ); } diff --git a/flutter_client/lib/requests/requests_provider.dart b/flutter_client/lib/requests/requests_provider.dart new file mode 100644 index 00000000..259329c4 --- /dev/null +++ b/flutter_client/lib/requests/requests_provider.dart @@ -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((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> { + static const Duration _pollInterval = Duration(seconds: 12); + + Timer? _pollTimer; + + @override + Future> 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 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 cancel(String id) async { + final api = await ref.read(requestsApiProvider.future); + final current = state.value ?? const []; + 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.new); diff --git a/flutter_client/lib/requests/requests_screen.dart b/flutter_client/lib/requests/requests_screen.dart new file mode 100644 index 00000000..0eb8f19a --- /dev/null +++ b/flutter_client/lib/requests/requests_screen.dart @@ -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()!; + 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()!; + 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( + 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()!; + 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()!; + 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), + ), + ); + } +} diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index 24f3728d..d1bdbab9 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -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()!; + 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. diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 88f7c5c3..189884f0 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -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()), diff --git a/flutter_client/test/requests/requests_screen_test.dart b/flutter_client/test/requests/requests_screen_test.dart new file mode 100644 index 00000000..fcd63c8e --- /dev/null +++ b/flutter_client/test/requests/requests_screen_test.dart @@ -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 build() async => + const User(id: 'u1', username: 'alice', isAdmin: false); +} + +class _StubRequests extends MyRequestsController { + _StubRequests(this._initial); + final List _initial; + @override + Future> 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 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); + }); +}