From bd1bcab12bcfde0f3ab2054de4ffbf8d3b71f7ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:54:58 -0400 Subject: [PATCH] feat(flutter/admin): requests screen with approve/reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminRequestsScreen renders rows from adminRequestsProvider, joining requester user_id → username via adminUsersProvider for display (falls back to UUID prefix when the users list hasn't loaded yet). Approve fires immediately; Reject confirms via dialog. Both are optimistic with rollback in the controller. Adds AdminUsersController build-only (the read side) so the row join works; mutation methods land in Slice 3 alongside the Users screen. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_providers.dart | 17 ++++ .../lib/admin/admin_requests_screen.dart | 71 ++++++++++++++ .../lib/admin/widgets/admin_request_row.dart | 75 +++++++++++++++ .../admin/admin_requests_screen_test.dart | 94 +++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 flutter_client/lib/admin/admin_requests_screen.dart create mode 100644 flutter_client/lib/admin/widgets/admin_request_row.dart create mode 100644 flutter_client/test/admin/admin_requests_screen_test.dart diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart index 75b24ba5..fa65419b 100644 --- a/flutter_client/lib/admin/admin_providers.dart +++ b/flutter_client/lib/admin/admin_providers.dart @@ -6,6 +6,7 @@ import '../api/endpoints/admin_requests.dart'; import '../api/endpoints/admin_users.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/admin_request.dart'; +import '../models/admin_user.dart'; final adminRequestsApiProvider = FutureProvider((ref) async { return AdminRequestsApi(await ref.watch(dioProvider.future)); @@ -89,3 +90,19 @@ class AdminRequestsController extends AsyncNotifier> { final adminRequestsProvider = AsyncNotifierProvider>( AdminRequestsController.new); + +/// Read side defined here so the Requests screen can join requester +/// UUID → username for display. Mutation methods (setAdmin, +/// setAutoApprove, resetPassword, delete) are appended in Slice 3 +/// alongside the Users screen. +class AdminUsersController extends AsyncNotifier> { + @override + Future> build() async { + final api = await ref.watch(adminUsersApiProvider.future); + return api.list(); + } +} + +final adminUsersProvider = + AsyncNotifierProvider>( + AdminUsersController.new); diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart new file mode 100644 index 00000000..0c5acbaa --- /dev/null +++ b/flutter_client/lib/admin/admin_requests_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/admin_user.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; +import '../theme/theme_extension.dart'; +import 'admin_providers.dart'; +import 'widgets/admin_request_row.dart'; + +class AdminRequestsScreen extends ConsumerWidget { + const AdminRequestsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final requests = ref.watch(adminRequestsProvider); + // Best-effort lookup for requester usernames. If the users provider + // hasn't loaded yet, valueOrNull is null and rows fall back to the + // UUID prefix; no blocking spinner. + final users = ref.watch(adminUsersProvider).valueOrNull ?? const []; + final usersById = {for (final u in users) u.id: u}; + + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Requests', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/admin/requests')], + ), + body: SafeArea( + child: requests.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => + Center(child: Text('$e', style: TextStyle(color: fs.error))), + data: (rows) { + if (rows.isEmpty) { + return Center( + child: Text('No pending requests.', + style: TextStyle(color: fs.ash)), + ); + } + final notifier = ref.read(adminRequestsProvider.notifier); + return RefreshIndicator( + onRefresh: () async => + ref.refresh(adminRequestsProvider.future), + child: ListView.builder( + itemCount: rows.length, + itemBuilder: (_, i) { + final req = rows[i]; + final user = usersById[req.userId]; + final display = user?.username ?? + (req.userId.length >= 8 + ? req.userId.substring(0, 8) + : req.userId); + return AdminRequestRow( + key: Key('admin_request_row_${req.id}'), + request: req, + requesterDisplay: display, + onApprove: () => notifier.approve(req.id), + onReject: () => notifier.reject(req.id), + ); + }, + ), + ); + }, + ), + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/admin_request_row.dart b/flutter_client/lib/admin/widgets/admin_request_row.dart new file mode 100644 index 00000000..f826556c --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_request_row.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../models/admin_request.dart'; +import '../../theme/theme_extension.dart'; + +class AdminRequestRow extends StatelessWidget { + const AdminRequestRow({ + super.key, + required this.request, + required this.requesterDisplay, + required this.onApprove, + required this.onReject, + }); + + final AdminRequest request; + + /// Pre-resolved username (or UUID-prefix fallback) for the requester. + final String requesterDisplay; + + final VoidCallback onApprove; + final VoidCallback onReject; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + final subtitle = + '${request.kind} · ${request.artistName} · requested by $requesterDisplay'; + return ListTile( + title: Text( + request.displayName, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 16, + ), + ), + subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onApprove, + style: TextButton.styleFrom(foregroundColor: fs.moss), + child: const Text('Approve'), + ), + TextButton( + onPressed: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reject request?'), + content: Text('Reject "${request.displayName}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: fs.oxblood), + child: const Text('Reject'), + ), + ], + ), + ); + if (ok == true) onReject(); + }, + style: TextButton.styleFrom(foregroundColor: fs.oxblood), + child: const Text('Reject'), + ), + ], + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_requests_screen_test.dart b/flutter_client/test/admin/admin_requests_screen_test.dart new file mode 100644 index 00000000..b57031e8 --- /dev/null +++ b/flutter_client/test/admin/admin_requests_screen_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/admin/admin_providers.dart'; +import 'package:minstrel/admin/admin_requests_screen.dart'; +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/models/admin_request.dart'; +import 'package:minstrel/models/admin_user.dart'; +import 'package:minstrel/models/user.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _StubAuth extends AuthController { + @override + Future build() async => + const User(id: 'u1', username: 'admin', isAdmin: true); +} + +class _StubRequests extends AdminRequestsController { + _StubRequests(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +class _StubUsers extends AdminUsersController { + _StubUsers(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +const _row = AdminRequest( + id: 'r1', + userId: 'user-uuid-1', + status: 'pending', + kind: 'album', + artistName: 'Some Artist', + albumTitle: 'Test Album', + trackTitle: null, + requestedAt: '2026-05-08T00:00:00Z', + decidedAt: null, + notes: null, + importedAlbumCount: 0, + importedTrackCount: 0, +); + +const _alice = AdminUser( + id: 'user-uuid-1', + username: 'alice', + displayName: null, + isAdmin: false, + autoApproveRequests: false, + createdAt: '2026-05-01T00:00:00Z', +); + +Widget _harness({ + required List requests, + required List users, +}) => + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth()), + adminRequestsProvider.overrideWith(() => _StubRequests(requests)), + adminUsersProvider.overrideWith(() => _StubUsers(users)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: const AdminRequestsScreen(), + ), + ); + +void main() { + testWidgets('renders empty state when no requests', (t) async { + await t.pumpWidget(_harness(requests: const [], users: const [])); + await t.pumpAndSettle(); + expect(find.text('No pending requests.'), findsOneWidget); + }); + + testWidgets('renders row with display name + joined requester username', + (t) async { + await t.pumpWidget(_harness(requests: const [_row], users: const [_alice])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('admin_request_row_r1')), findsOneWidget); + expect(find.text('Test Album'), findsOneWidget); + expect(find.textContaining('requested by alice'), findsOneWidget); + }); + + testWidgets('falls back to uuid prefix when username unknown', (t) async { + await t.pumpWidget(_harness(requests: const [_row], users: const [])); + await t.pumpAndSettle(); + expect(find.textContaining('requested by user-uui'), findsOneWidget); + }); +}