From 6564d37a2a4140cd8ca355befd0d383931b69942 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:48:52 -0400 Subject: [PATCH 01/16] feat(flutter): MainAppBarActions widget with admin-gated overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared AppBar actions for top-level screens — three primary icons (Home/Library/Search, current screen suppressed) plus a kebab containing Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the ad-hoc per-screen IconButton lists; centralises admin-gating logic in one place so /admin entry can't accidentally leak to non-admins. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/widgets/main_app_bar_actions.dart | 63 +++++++++++++++++ .../shared/main_app_bar_actions_test.dart | 70 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 flutter_client/lib/shared/widgets/main_app_bar_actions.dart create mode 100644 flutter_client/test/shared/main_app_bar_actions_test.dart diff --git a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart new file mode 100644 index 00000000..eedc72a4 --- /dev/null +++ b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../auth/auth_provider.dart'; +import '../../theme/theme_extension.dart'; + +/// Shared AppBar `actions` for top-level screens. Renders Home / Library / +/// Search as primary icons (suppressing the icon for [currentRoute]) plus +/// a kebab overflow with Playlists / Discover / Settings and (for admins) +/// Admin. +class MainAppBarActions extends ConsumerWidget { + const MainAppBarActions({super.key, required this.currentRoute}); + + final String currentRoute; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final user = ref.watch(authControllerProvider).valueOrNull; + final isAdmin = user?.isAdmin ?? false; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (currentRoute != '/home') + IconButton( + key: const Key('app_bar_home'), + icon: Icon(Icons.home, color: fs.parchment), + tooltip: 'Home', + onPressed: () => context.go('/home'), + ), + if (currentRoute != '/library') + IconButton( + key: const Key('app_bar_library'), + icon: Icon(Icons.library_music, color: fs.parchment), + tooltip: 'Library', + onPressed: () => context.push('/library'), + ), + if (currentRoute != '/search') + IconButton( + key: const Key('app_bar_search'), + icon: Icon(Icons.search, color: fs.parchment), + tooltip: 'Search', + onPressed: () => context.push('/search'), + ), + PopupMenuButton( + key: const Key('app_bar_overflow'), + icon: Icon(Icons.more_vert, color: fs.parchment), + tooltip: 'More', + onSelected: (route) => context.push(route), + itemBuilder: (_) => [ + const PopupMenuItem(value: '/playlists', child: Text('Playlists')), + const PopupMenuItem(value: '/discover', child: Text('Discover')), + const PopupMenuItem(value: '/settings', child: Text('Settings')), + if (isAdmin) + const PopupMenuItem(value: '/admin', child: Text('Admin')), + ], + ), + ], + ); + } +} diff --git a/flutter_client/test/shared/main_app_bar_actions_test.dart b/flutter_client/test/shared/main_app_bar_actions_test.dart new file mode 100644 index 00000000..495369ae --- /dev/null +++ b/flutter_client/test/shared/main_app_bar_actions_test.dart @@ -0,0 +1,70 @@ +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/user.dart'; +import 'package:minstrel/shared/widgets/main_app_bar_actions.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _StubAuth extends AuthController { + _StubAuth(this._user); + final User? _user; + @override + Future build() async => _user; +} + +Widget _harness({required User? user, required String currentRoute}) { + return ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth(user)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: Scaffold( + appBar: AppBar( + actions: [MainAppBarActions(currentRoute: currentRoute)], + ), + ), + ), + ); +} + +void main() { + const adminUser = User(id: 'u1', username: 'admin', isAdmin: true); + const regularUser = User(id: 'u2', username: 'alice', isAdmin: false); + + testWidgets('renders Library + Search primary icons + kebab on /home', + (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('app_bar_library')), findsOneWidget); + expect(find.byKey(const Key('app_bar_search')), findsOneWidget); + expect(find.byKey(const Key('app_bar_home')), findsNothing); + expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget); + }); + + testWidgets('suppresses Library icon when currentRoute is /library', + (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('app_bar_library')), findsNothing); + expect(find.byKey(const Key('app_bar_home')), findsOneWidget); + }); + + testWidgets('overflow includes Admin only for admin users', (t) async { + await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('app_bar_overflow'))); + await t.pumpAndSettle(); + expect(find.text('Admin'), findsOneWidget); + }); + + testWidgets('overflow omits Admin for non-admin users', (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('app_bar_overflow'))); + await t.pumpAndSettle(); + expect(find.text('Admin'), findsNothing); + }); +} From 5546787f787ac2ff1552a711ba8f43933d5f19e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:49:30 -0400 Subject: [PATCH 02/16] feat(flutter): adopt MainAppBarActions in Home + Library HomeScreen drops its 5 ad-hoc IconButton actions in favour of the shared widget. LibraryScreen gains an actions row it didn't have before. Both now consistent. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/library/home_screen.dart | 30 ++----------------- .../lib/library/library_screen.dart | 2 ++ 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index ab623d79..3c4c404f 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import '../api/errors.dart'; import '../models/album.dart'; @@ -9,6 +8,7 @@ import '../models/artist.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; import '../shared/widgets/connection_error_banner.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'library_providers.dart'; import 'widgets/album_card.dart'; @@ -28,33 +28,7 @@ class HomeScreen extends ConsumerWidget { backgroundColor: fs.obsidian, elevation: 0, title: Text('Minstrel', style: TextStyle(color: fs.parchment)), - actions: [ - IconButton( - icon: Icon(Icons.library_music, color: fs.parchment), - tooltip: 'Library', - onPressed: () => context.push('/library'), - ), - IconButton( - icon: Icon(Icons.queue_music, color: fs.parchment), - tooltip: 'Playlists', - onPressed: () => context.push('/playlists'), - ), - IconButton( - icon: Icon(Icons.search, color: fs.parchment), - tooltip: 'Search', - onPressed: () => context.push('/search'), - ), - IconButton( - icon: Icon(Icons.explore, color: fs.parchment), - tooltip: 'Discover', - onPressed: () => context.push('/discover'), - ), - IconButton( - icon: Icon(Icons.settings, color: fs.parchment), - tooltip: 'Settings', - onPressed: () => context.push('/settings'), - ), - ], + actions: const [MainAppBarActions(currentRoute: '/home')], ), body: SafeArea( child: ref.watch(homeProvider).when( diff --git a/flutter_client/lib/library/library_screen.dart b/flutter_client/lib/library/library_screen.dart index 4a9decb3..f3b2f4b7 100644 --- a/flutter_client/lib/library/library_screen.dart +++ b/flutter_client/lib/library/library_screen.dart @@ -15,6 +15,7 @@ import '../models/page.dart' as wire; import '../models/quarantine_mine.dart'; import '../models/track.dart'; import '../player/player_provider.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'widgets/album_card.dart'; import 'widgets/artist_card.dart'; @@ -90,6 +91,7 @@ class _LibraryScreenState extends ConsumerState backgroundColor: fs.obsidian, elevation: 0, title: Text('Library', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/library')], bottom: TabBar( controller: _ctrl, isScrollable: true, From fc0350cb96be50d3958a03d9a0d9359c19d5e3ea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:50:29 -0400 Subject: [PATCH 03/16] feat(flutter): adopt MainAppBarActions in Search, Discover, Playlists, Settings Search keeps its conditional clear-text button and gains the shared nav widget after it. Discover/Playlists/Settings get an actions row they didn't have before. The kebab is now reachable from every top-level screen, which is the prerequisite for the Admin entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/discover/discover_screen.dart | 2 ++ flutter_client/lib/playlists/playlists_list_screen.dart | 2 ++ flutter_client/lib/search/search_screen.dart | 2 ++ flutter_client/lib/settings/settings_screen.dart | 2 ++ 4 files changed, 8 insertions(+) diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index 78345b4e..a55ea07b 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -7,6 +7,7 @@ import '../api/endpoints/discover.dart'; import '../api/errors.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/lidarr.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; final _discoverApiProvider = FutureProvider((ref) async { @@ -87,6 +88,7 @@ class _DiscoverScreenState extends ConsumerState { onPressed: () => context.pop(), ), title: Text('Discover', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/discover')], ), body: Column(children: [ Padding( diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart index f71c029d..e34cc962 100644 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../models/playlist.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; @@ -22,6 +23,7 @@ class PlaylistsListScreen extends ConsumerWidget { backgroundColor: fs.obsidian, elevation: 0, title: Text('Playlists', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/playlists')], ), body: list.when( loading: () => const Center(child: CircularProgressIndicator()), diff --git a/flutter_client/lib/search/search_screen.dart b/flutter_client/lib/search/search_screen.dart index e3473d27..40874fbe 100644 --- a/flutter_client/lib/search/search_screen.dart +++ b/flutter_client/lib/search/search_screen.dart @@ -7,6 +7,7 @@ import '../library/widgets/artist_card.dart'; import '../library/widgets/track_row.dart'; import '../models/search_response.dart'; import '../player/player_provider.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; import 'search_provider.dart'; @@ -73,6 +74,7 @@ class _SearchScreenState extends ConsumerState { _focus.requestFocus(); }, ), + const MainAppBarActions(currentRoute: '/search'), ], ), body: results.when( diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index ab27f0bb..110741e0 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -7,6 +7,7 @@ import '../api/endpoints/settings.dart'; import '../api/errors.dart'; import '../library/library_providers.dart' show dioProvider; import '../models/my_profile.dart'; +import '../shared/widgets/main_app_bar_actions.dart'; import '../theme/theme_extension.dart'; final _settingsApiProvider = FutureProvider((ref) async { @@ -37,6 +38,7 @@ class SettingsScreen extends ConsumerWidget { onPressed: () => context.pop(), ), title: Text('Settings', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/settings')], ), body: ListView( padding: const EdgeInsets.symmetric(vertical: 8), From dfc08650e705b2b4acc787d052df8ac533ae24df Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:50:53 -0400 Subject: [PATCH 04/16] feat(flutter): wire admin routes + isAdmin gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /admin, /admin/requests, /admin/quarantine, /admin/users routes to the ShellRoute (so PlayerBar persists). Redirect closure refuses any /admin path when user.isAdmin is false. Server-side RequireAdmin middleware remains the actual authority — this is UX. Screens land in subsequent commits; this intermediate state won't build until task 8/9 lands the AdminLandingScreen and the missing siblings are stubbed out by task 11/13/16. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/shared/routing.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 1c30c297..88f7c5c3 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -17,6 +17,10 @@ import '../playlists/playlist_detail_screen.dart'; import '../playlists/playlists_list_screen.dart'; import '../search/search_screen.dart'; import '../settings/settings_screen.dart'; +import '../admin/admin_landing_screen.dart'; +import '../admin/admin_requests_screen.dart'; +import '../admin/admin_quarantine_screen.dart'; +import '../admin/admin_users_screen.dart'; import 'widgets/version_gate.dart'; /// Exposed as a Provider so its single argument is a real `Ref` (the @@ -39,6 +43,9 @@ GoRouter buildRouter(Ref ref) { if (user != null && (loc == '/login' || loc == '/server-url')) { return '/home'; } + if (loc.startsWith('/admin') && !user!.isAdmin) { + return '/home'; + } return null; }, routes: [ @@ -68,6 +75,10 @@ GoRouter buildRouter(Ref ref) { path: '/playlists/:id', builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!), ), + GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()), + GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()), + GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()), + GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()), ], ), ], From 2b595c40cd7c5858585770b1e77674f3e4fce1fe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:51:33 -0400 Subject: [PATCH 05/16] feat(flutter): admin DTOs (request, quarantine item, user, invite) Hand-rolled to match the server's actual JSON shapes: - AdminRequest carries user_id (UUID, not username); kind switches which title field is the display name - AdminQuarantineItem aggregates reports per track with reason_counts and a nested reports[] list, mirroring adminQueueRowView - AdminUser includes display_name + auto_approve_requests - Invite uses invited_by UUID + note (server hardcodes 24h TTL) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/models/admin_quarantine_item.dart | 92 +++++++++++++++++++ flutter_client/lib/models/admin_request.dart | 64 +++++++++++++ flutter_client/lib/models/admin_user.dart | 33 +++++++ flutter_client/lib/models/invite.dart | 40 ++++++++ 4 files changed, 229 insertions(+) create mode 100644 flutter_client/lib/models/admin_quarantine_item.dart create mode 100644 flutter_client/lib/models/admin_request.dart create mode 100644 flutter_client/lib/models/admin_user.dart create mode 100644 flutter_client/lib/models/invite.dart diff --git a/flutter_client/lib/models/admin_quarantine_item.dart b/flutter_client/lib/models/admin_quarantine_item.dart new file mode 100644 index 00000000..db1aed39 --- /dev/null +++ b/flutter_client/lib/models/admin_quarantine_item.dart @@ -0,0 +1,92 @@ +/// One user's report under an aggregated quarantine row. +class AdminQuarantineReport { + const AdminQuarantineReport({ + required this.userId, + required this.username, + required this.reason, + this.notes, + required this.createdAt, + }); + + final String userId; + final String username; + final String reason; + final String? notes; + final String createdAt; + + factory AdminQuarantineReport.fromJson(Map j) => + AdminQuarantineReport( + userId: j['user_id'] as String? ?? '', + username: j['username'] as String? ?? '', + reason: j['reason'] as String? ?? '', + notes: j['notes'] as String?, + createdAt: j['created_at'] as String? ?? '', + ); +} + +/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go. +/// One row aggregates all reports against a single track, with +/// per-reason counts and the underlying per-user reports nested. +class AdminQuarantineItem { + const AdminQuarantineItem({ + required this.trackId, + required this.trackTitle, + required this.artistName, + required this.albumTitle, + required this.albumId, + this.lidarrAlbumMbid, + required this.reportCount, + required this.latestAt, + required this.reasonCounts, + required this.reports, + }); + + final String trackId; + final String trackTitle; + final String artistName; + final String albumTitle; + final String albumId; + final String? lidarrAlbumMbid; + final int reportCount; + final String latestAt; + + /// Map of reason → number of reports citing that reason. + final Map reasonCounts; + + final List reports; + + /// One-line summary of the most-cited reason. Returns "" when + /// only one reason exists or " (+N more)" when there are + /// additional distinct reasons. + String get topReasonSummary { + if (reasonCounts.isEmpty) return ''; + final entries = reasonCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = entries.first.key; + return entries.length > 1 + ? '$top (+${entries.length - 1} more)' + : top; + } + + factory AdminQuarantineItem.fromJson(Map j) { + final reasonCountsRaw = + (j['reason_counts'] as Map?)?.cast() ?? const {}; + final reportsRaw = (j['reports'] as List?) ?? const []; + return AdminQuarantineItem( + trackId: j['track_id'] as String? ?? '', + trackTitle: j['track_title'] as String? ?? '', + artistName: j['artist_name'] as String? ?? '', + albumTitle: j['album_title'] as String? ?? '', + albumId: j['album_id'] as String? ?? '', + lidarrAlbumMbid: j['lidarr_album_mbid'] as String?, + reportCount: (j['report_count'] as int?) ?? 0, + latestAt: j['latest_at'] as String? ?? '', + reasonCounts: + reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)), + reports: reportsRaw + .map((e) => + AdminQuarantineReport.fromJson((e as Map).cast())) + .toList(growable: false), + ); + } +} diff --git a/flutter_client/lib/models/admin_request.dart b/flutter_client/lib/models/admin_request.dart new file mode 100644 index 00000000..da7ba69a --- /dev/null +++ b/flutter_client/lib/models/admin_request.dart @@ -0,0 +1,64 @@ +/// 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, + }); + + 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; + + /// 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 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, + ); +} diff --git a/flutter_client/lib/models/admin_user.dart b/flutter_client/lib/models/admin_user.dart new file mode 100644 index 00000000..46c4a80b --- /dev/null +++ b/flutter_client/lib/models/admin_user.dart @@ -0,0 +1,33 @@ +/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct +/// from MyProfile because admin endpoints expose `auto_approve_requests` +/// and the canonical `created_at` that the user-scoped /me response +/// intentionally omits. +/// +/// The list endpoint wraps these in `{"users": [...]}`; the parsing of +/// the envelope is done in `AdminUsersApi`, not here. +class AdminUser { + const AdminUser({ + required this.id, + required this.username, + this.displayName, + required this.isAdmin, + required this.autoApproveRequests, + required this.createdAt, + }); + + final String id; + final String username; + final String? displayName; + final bool isAdmin; + final bool autoApproveRequests; + final String createdAt; + + factory AdminUser.fromJson(Map j) => AdminUser( + id: j['id'] as String? ?? '', + username: j['username'] as String? ?? '', + displayName: j['display_name'] as String?, + isAdmin: j['is_admin'] as bool? ?? false, + autoApproveRequests: j['auto_approve_requests'] as bool? ?? false, + createdAt: j['created_at'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/invite.dart b/flutter_client/lib/models/invite.dart new file mode 100644 index 00000000..446a4394 --- /dev/null +++ b/flutter_client/lib/models/invite.dart @@ -0,0 +1,40 @@ +/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list +/// endpoint wraps these in `{"invites": [...]}`; the create endpoint +/// returns a single bare invite. Envelope parsing is done in +/// `AdminInvitesApi`, not here. +/// +/// `invitedBy` is the UUID of the inviting admin (not their username); +/// the server doesn't currently denormalize it. Likewise `redeemedBy`. +/// TTL is hardcoded server-side at 24h, so admins can't customise the +/// expiry — only the optional `note`. +class Invite { + const Invite({ + required this.token, + required this.invitedBy, + this.note, + required this.createdAt, + required this.expiresAt, + this.redeemedAt, + this.redeemedBy, + }); + + final String token; + final String invitedBy; + final String? note; + final String createdAt; + final String expiresAt; + final String? redeemedAt; + final String? redeemedBy; + + bool get isRedeemed => redeemedAt != null; + + factory Invite.fromJson(Map j) => Invite( + token: j['token'] as String? ?? '', + invitedBy: j['invited_by'] as String? ?? '', + note: j['note'] as String?, + createdAt: j['created_at'] as String? ?? '', + expiresAt: j['expires_at'] as String? ?? '', + redeemedAt: j['redeemed_at'] as String?, + redeemedBy: j['redeemed_by'] as String?, + ); +} From 79d00ba00111f5439dc229e380c8b61007683b93 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:52:00 -0400 Subject: [PATCH 06/16] feat(flutter/api): admin endpoint clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small Dio-backed API classes mirroring the server's actual contract (verified against internal/api/admin_*.go): - Requests/Quarantine return flat lists; Users/Invites are enveloped ({"users": [...]}, {"invites": [...]}) and unwrap here - setAutoApprove sends {auto_approve: bool} — different from the response field name auto_approve_requests - resetPassword takes admin-supplied {password: string}, returns 204 - Invite create takes optional {note: string}; expiry is server-fixed at 24h Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/api/endpoints/admin_invites.dart | 32 +++++++++++++ .../lib/api/endpoints/admin_quarantine.dart | 31 ++++++++++++ .../lib/api/endpoints/admin_requests.dart | 26 ++++++++++ .../lib/api/endpoints/admin_users.dart | 47 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 flutter_client/lib/api/endpoints/admin_invites.dart create mode 100644 flutter_client/lib/api/endpoints/admin_quarantine.dart create mode 100644 flutter_client/lib/api/endpoints/admin_requests.dart create mode 100644 flutter_client/lib/api/endpoints/admin_users.dart diff --git a/flutter_client/lib/api/endpoints/admin_invites.dart b/flutter_client/lib/api/endpoints/admin_invites.dart new file mode 100644 index 00000000..e5ab6142 --- /dev/null +++ b/flutter_client/lib/api/endpoints/admin_invites.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; + +import '../../models/invite.dart'; + +class AdminInvitesApi { + AdminInvitesApi(this._dio); + final Dio _dio; + + /// GET /api/admin/invites → `{"invites": [...]}`. Envelope unwrapped. + Future> list() async { + final r = await _dio.get>('/api/admin/invites'); + final raw = (r.data?['invites'] as List?) ?? const []; + return raw + .map((e) => Invite.fromJson((e as Map).cast())) + .toList(growable: false); + } + + /// POST /api/admin/invites — body is optional `{"note": "..."}`. + /// Server hardcodes the 24h TTL; admins cannot configure expiry. + /// Returns the bare invite (not enveloped). + Future create({String? note}) async { + final r = await _dio.post>( + '/api/admin/invites', + data: {if (note != null && note.isNotEmpty) 'note': note}, + ); + return Invite.fromJson(r.data ?? const {}); + } + + Future revoke(String token) async { + await _dio.delete('/api/admin/invites/$token'); + } +} diff --git a/flutter_client/lib/api/endpoints/admin_quarantine.dart b/flutter_client/lib/api/endpoints/admin_quarantine.dart new file mode 100644 index 00000000..e72736c0 --- /dev/null +++ b/flutter_client/lib/api/endpoints/admin_quarantine.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +import '../../models/admin_quarantine_item.dart'; + +class AdminQuarantineApi { + AdminQuarantineApi(this._dio); + final Dio _dio; + + /// GET /api/admin/quarantine — flat list (no envelope) of aggregated + /// quarantine rows. + Future> list() async { + final r = await _dio.get>('/api/admin/quarantine'); + final raw = r.data ?? const []; + return raw + .map((e) => + AdminQuarantineItem.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future resolve(String trackId) async { + await _dio.post('/api/admin/quarantine/$trackId/resolve'); + } + + Future deleteFile(String trackId) async { + await _dio.post('/api/admin/quarantine/$trackId/delete-file'); + } + + Future deleteViaLidarr(String trackId) async { + await _dio.post('/api/admin/quarantine/$trackId/delete-via-lidarr'); + } +} diff --git a/flutter_client/lib/api/endpoints/admin_requests.dart b/flutter_client/lib/api/endpoints/admin_requests.dart new file mode 100644 index 00000000..519cd7bd --- /dev/null +++ b/flutter_client/lib/api/endpoints/admin_requests.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; + +import '../../models/admin_request.dart'; + +class AdminRequestsApi { + AdminRequestsApi(this._dio); + final Dio _dio; + + /// GET /api/admin/requests — flat list of pending requests. + /// Server defaults to ?status=pending; we don't override. + Future> list() async { + final r = await _dio.get>('/api/admin/requests'); + final raw = r.data ?? const []; + return raw + .map((e) => AdminRequest.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future approve(String id) async { + await _dio.post('/api/admin/requests/$id/approve'); + } + + Future reject(String id) async { + await _dio.post('/api/admin/requests/$id/reject'); + } +} diff --git a/flutter_client/lib/api/endpoints/admin_users.dart b/flutter_client/lib/api/endpoints/admin_users.dart new file mode 100644 index 00000000..92252c57 --- /dev/null +++ b/flutter_client/lib/api/endpoints/admin_users.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; + +import '../../models/admin_user.dart'; + +class AdminUsersApi { + AdminUsersApi(this._dio); + final Dio _dio; + + /// GET /api/admin/users → `{"users": [...]}`. Envelope unwrapped here. + Future> list() async { + final r = await _dio.get>('/api/admin/users'); + final raw = (r.data?['users'] as List?) ?? const []; + return raw + .map((e) => AdminUser.fromJson((e as Map).cast())) + .toList(growable: false); + } + + Future setAdmin(String id, bool isAdmin) async { + await _dio.put( + '/api/admin/users/$id/admin', + data: {'is_admin': isAdmin}, + ); + } + + /// Server's body field is `auto_approve` (NOT `auto_approve_requests`) + /// — different from the response field name. Verified against + /// internal/api/admin_users.go `adminAutoApproveReq` (May 2026). + Future setAutoApprove(String id, bool autoApprove) async { + await _dio.put( + '/api/admin/users/$id/auto-approve', + data: {'auto_approve': autoApprove}, + ); + } + + /// Admin supplies the new password; server returns 204. There is no + /// server-generated-password mode. + Future resetPassword(String id, String newPassword) async { + await _dio.post( + '/api/admin/users/$id/reset-password', + data: {'password': newPassword}, + ); + } + + Future delete(String id) async { + await _dio.delete('/api/admin/users/$id'); + } +} From 372e7eca86013b16a0724545806df324cb0e17c3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:52:12 -0400 Subject: [PATCH 07/16] feat(flutter/admin): API providers + adminCountsProvider Single landing-screen provider fans out to requests/quarantine/users list endpoints in parallel and returns counts for the section-card badges. Per-section AsyncNotifier controllers land alongside their screens in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_providers.dart | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 flutter_client/lib/admin/admin_providers.dart diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart new file mode 100644 index 00000000..a812380e --- /dev/null +++ b/flutter_client/lib/admin/admin_providers.dart @@ -0,0 +1,53 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/endpoints/admin_invites.dart'; +import '../api/endpoints/admin_quarantine.dart'; +import '../api/endpoints/admin_requests.dart'; +import '../api/endpoints/admin_users.dart'; +import '../library/library_providers.dart' show dioProvider; + +final adminRequestsApiProvider = FutureProvider((ref) async { + return AdminRequestsApi(await ref.watch(dioProvider.future)); +}); + +final adminQuarantineApiProvider = + FutureProvider((ref) async { + return AdminQuarantineApi(await ref.watch(dioProvider.future)); +}); + +final adminUsersApiProvider = FutureProvider((ref) async { + return AdminUsersApi(await ref.watch(dioProvider.future)); +}); + +final adminInvitesApiProvider = FutureProvider((ref) async { + return AdminInvitesApi(await ref.watch(dioProvider.future)); +}); + +class AdminCounts { + const AdminCounts({ + required this.requests, + required this.quarantine, + required this.users, + }); + final int requests; + final int quarantine; + final int users; +} + +/// Fans out to the three list endpoints in parallel and rolls them up +/// into `{requests, quarantine, users}` for the landing screen badges. +final adminCountsProvider = FutureProvider((ref) async { + final requestsApi = await ref.watch(adminRequestsApiProvider.future); + final quarantineApi = await ref.watch(adminQuarantineApiProvider.future); + final usersApi = await ref.watch(adminUsersApiProvider.future); + final results = await Future.wait([ + requestsApi.list(), + quarantineApi.list(), + usersApi.list(), + ]); + return AdminCounts( + requests: (results[0] as List).length, + quarantine: (results[1] as List).length, + users: (results[2] as List).length, + ); +}); From 6b5d12f3a1461a265494740b8086005e71d504f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:53:03 -0400 Subject: [PATCH 08/16] feat(flutter/admin): landing screen with section cards + counts ListView of three AdminSectionCard tiles (Requests / Quarantine / Users) showing live counts from adminCountsProvider. Pull-to-refresh re-fans-out to the three list endpoints. Admin/Quarantine/Users sub-routes will land in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/admin/admin_landing_screen.dart | 66 +++++++++++++++++++ .../lib/admin/widgets/admin_section_card.dart | 53 +++++++++++++++ .../test/admin/admin_landing_screen_test.dart | 46 +++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 flutter_client/lib/admin/admin_landing_screen.dart create mode 100644 flutter_client/lib/admin/widgets/admin_section_card.dart create mode 100644 flutter_client/test/admin/admin_landing_screen_test.dart diff --git a/flutter_client/lib/admin/admin_landing_screen.dart b/flutter_client/lib/admin/admin_landing_screen.dart new file mode 100644 index 00000000..3849c77f --- /dev/null +++ b/flutter_client/lib/admin/admin_landing_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../shared/widgets/main_app_bar_actions.dart'; +import '../theme/theme_extension.dart'; +import 'admin_providers.dart'; +import 'widgets/admin_section_card.dart'; + +class AdminLandingScreen extends ConsumerWidget { + const AdminLandingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final counts = ref.watch(adminCountsProvider); + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Admin', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/admin')], + ), + body: SafeArea( + child: counts.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (c) => RefreshIndicator( + onRefresh: () async => ref.refresh(adminCountsProvider.future), + child: ListView( + children: [ + AdminSectionCard( + key: const Key('admin_card_requests'), + icon: Icons.inbox, + title: 'Requests', + subtitle: 'Approve or reject Lidarr requests', + count: c.requests, + onTap: () => context.push('/admin/requests'), + ), + AdminSectionCard( + key: const Key('admin_card_quarantine'), + icon: Icons.warning_amber, + title: 'Quarantine', + subtitle: 'Resolve scan failures', + count: c.quarantine, + onTap: () => context.push('/admin/quarantine'), + ), + AdminSectionCard( + key: const Key('admin_card_users'), + icon: Icons.people, + title: 'Users', + subtitle: 'Manage accounts and invites', + count: c.users, + onTap: () => context.push('/admin/users'), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/admin_section_card.dart b/flutter_client/lib/admin/widgets/admin_section_card.dart new file mode 100644 index 00000000..919441de --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_section_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../../theme/theme_extension.dart'; + +class AdminSectionCard extends StatelessWidget { + const AdminSectionCard({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.count, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final int count; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Card( + color: fs.iron, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ListTile( + leading: Icon(icon, color: fs.parchment), + title: Text( + title, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 18, + ), + ), + subtitle: Text(subtitle, style: TextStyle(color: fs.ash)), + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: fs.bronze, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$count', + style: TextStyle(color: fs.obsidian, fontWeight: FontWeight.w500), + ), + ), + onTap: onTap, + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_landing_screen_test.dart b/flutter_client/test/admin/admin_landing_screen_test.dart new file mode 100644 index 00000000..95eca570 --- /dev/null +++ b/flutter_client/test/admin/admin_landing_screen_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/admin/admin_landing_screen.dart'; +import 'package:minstrel/admin/admin_providers.dart'; +import 'package:minstrel/auth/auth_provider.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); +} + +void main() { + testWidgets('renders three section cards with counts from provider', + (t) async { + await t.pumpWidget( + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth()), + adminCountsProvider.overrideWith( + (_) async => const AdminCounts( + requests: 3, + quarantine: 1, + users: 5, + ), + ), + ], + child: MaterialApp( + theme: buildThemeData(), + home: const AdminLandingScreen(), + ), + ), + ); + await t.pumpAndSettle(); + expect(find.byKey(const Key('admin_card_requests')), findsOneWidget); + expect(find.byKey(const Key('admin_card_quarantine')), findsOneWidget); + expect(find.byKey(const Key('admin_card_users')), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + }); +} From 408eed1f3de9a13080534cead51e1a476c31ef10 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:53:35 -0400 Subject: [PATCH 09/16] feat(flutter/settings): admin card visible to admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gains an _AdminSection that renders an "Admin" tile (Shield icon → /admin) only when the current profile has is_admin = true. Non-admins see the section as SizedBox.shrink() so the const ListView children stay const. Provides a discoverable second entry to the admin landing alongside the kebab overflow shortcut. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/settings/settings_screen.dart | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index 110741e0..02ce6553 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -48,6 +48,7 @@ class SettingsScreen extends ConsumerWidget { _PasswordSection(), _Divider(), _ListenBrainzSection(), + _AdminSection(), SizedBox(height: 96), ], ), @@ -87,6 +88,44 @@ class _SectionHeader extends StatelessWidget { } } +/// 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. +class _AdminSection extends ConsumerWidget { + const _AdminSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final profile = ref.watch(_profileProvider).valueOrNull; + if (profile == null || !profile.isAdmin) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _Divider(), + ListTile( + key: const Key('settings_admin_card'), + leading: Icon(Icons.shield, color: fs.parchment), + title: Text( + 'Admin', + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 18, + ), + ), + subtitle: Text( + 'Manage requests, quarantine, and users', + style: TextStyle(color: fs.ash), + ), + trailing: Icon(Icons.chevron_right, color: fs.ash), + onTap: () => context.push('/admin'), + ), + ], + ); + } +} + class _ProfileSection extends ConsumerStatefulWidget { const _ProfileSection(); @override From 8bb192fe54e7b7f4fb393612ecc4c0035fd6f942 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:54:05 -0400 Subject: [PATCH 10/16] feat(flutter/admin): adminRequestsProvider with optimistic approve/reject AsyncNotifier removes the row optimistically on approve/reject; rolls back to the pre-mutation list on server error. Invalidates adminCountsProvider so the landing badge stays accurate. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_providers.dart | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart index a812380e..75b24ba5 100644 --- a/flutter_client/lib/admin/admin_providers.dart +++ b/flutter_client/lib/admin/admin_providers.dart @@ -5,6 +5,7 @@ import '../api/endpoints/admin_quarantine.dart'; import '../api/endpoints/admin_requests.dart'; import '../api/endpoints/admin_users.dart'; import '../library/library_providers.dart' show dioProvider; +import '../models/admin_request.dart'; final adminRequestsApiProvider = FutureProvider((ref) async { return AdminRequestsApi(await ref.watch(dioProvider.future)); @@ -51,3 +52,40 @@ final adminCountsProvider = FutureProvider((ref) async { users: (results[2] as List).length, ); }); + +class AdminRequestsController extends AsyncNotifier> { + @override + Future> build() async { + final api = await ref.watch(adminRequestsApiProvider.future); + return api.list(); + } + + Future approve(String id) async { + await _decide(id, (api) => api.approve(id)); + } + + Future reject(String id) async { + await _decide(id, (api) => api.reject(id)); + } + + Future _decide( + String id, + Future Function(AdminRequestsApi api) action, + ) async { + final api = await ref.read(adminRequestsApiProvider.future); + final current = state.value ?? const []; + state = AsyncData(current.where((r) => r.id != id).toList()); + try { + await action(api); + // Refresh landing badge. + ref.invalidate(adminCountsProvider); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } +} + +final adminRequestsProvider = + AsyncNotifierProvider>( + AdminRequestsController.new); From bd1bcab12bcfde0f3ab2054de4ffbf8d3b71f7ce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:54:58 -0400 Subject: [PATCH 11/16] 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); + }); +} From 3e905db906a848f9334ab4a254e2d0d67f51ee86 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:56:00 -0400 Subject: [PATCH 12/16] feat(flutter/admin): quarantine screen + typed-confirm sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpansionTile per aggregated quarantine row — collapsed shows track, artist · album, and "{N} reports: {top reason}". Expanded reveals the nested per-user reports with their reasons + optional notes. Three-dot menu offers Resolve / Delete file / Delete via Lidarr; both deletes route through TypedConfirmSheet which requires the user to type "DELETE" before the action button enables. Adds AdminQuarantineController with optimistic-removal + rollback, mirroring AdminRequestsController's pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_providers.dart | 36 +++++ .../lib/admin/admin_quarantine_screen.dart | 57 ++++++++ .../admin/widgets/admin_quarantine_row.dart | 101 ++++++++++++++ .../admin/widgets/typed_confirm_sheet.dart | 124 ++++++++++++++++++ .../admin/admin_quarantine_screen_test.dart | 94 +++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 flutter_client/lib/admin/admin_quarantine_screen.dart create mode 100644 flutter_client/lib/admin/widgets/admin_quarantine_row.dart create mode 100644 flutter_client/lib/admin/widgets/typed_confirm_sheet.dart create mode 100644 flutter_client/test/admin/admin_quarantine_screen_test.dart diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart index fa65419b..94ffefbe 100644 --- a/flutter_client/lib/admin/admin_providers.dart +++ b/flutter_client/lib/admin/admin_providers.dart @@ -5,6 +5,7 @@ import '../api/endpoints/admin_quarantine.dart'; import '../api/endpoints/admin_requests.dart'; import '../api/endpoints/admin_users.dart'; import '../library/library_providers.dart' show dioProvider; +import '../models/admin_quarantine_item.dart'; import '../models/admin_request.dart'; import '../models/admin_user.dart'; @@ -106,3 +107,38 @@ class AdminUsersController extends AsyncNotifier> { final adminUsersProvider = AsyncNotifierProvider>( AdminUsersController.new); + +class AdminQuarantineController + extends AsyncNotifier> { + @override + Future> build() async { + final api = await ref.watch(adminQuarantineApiProvider.future); + return api.list(); + } + + Future resolve(String trackId) => + _act(trackId, (api) => api.resolve(trackId)); + Future deleteFile(String trackId) => + _act(trackId, (api) => api.deleteFile(trackId)); + Future deleteViaLidarr(String trackId) => + _act(trackId, (api) => api.deleteViaLidarr(trackId)); + + Future _act( + String trackId, + Future Function(AdminQuarantineApi api) action, + ) async { + final api = await ref.read(adminQuarantineApiProvider.future); + final current = state.value ?? const []; + state = AsyncData(current.where((q) => q.trackId != trackId).toList()); + try { + await action(api); + ref.invalidate(adminCountsProvider); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } +} + +final adminQuarantineProvider = AsyncNotifierProvider>(AdminQuarantineController.new); diff --git a/flutter_client/lib/admin/admin_quarantine_screen.dart b/flutter_client/lib/admin/admin_quarantine_screen.dart new file mode 100644 index 00000000..d9c55a9d --- /dev/null +++ b/flutter_client/lib/admin/admin_quarantine_screen.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/widgets/main_app_bar_actions.dart'; +import '../theme/theme_extension.dart'; +import 'admin_providers.dart'; +import 'widgets/admin_quarantine_row.dart'; + +class AdminQuarantineScreen extends ConsumerWidget { + const AdminQuarantineScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final items = ref.watch(adminQuarantineProvider); + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Quarantine', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/admin/quarantine')], + ), + body: SafeArea( + child: items.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 quarantined tracks.', + style: TextStyle(color: fs.ash)), + ); + } + final notifier = ref.read(adminQuarantineProvider.notifier); + return RefreshIndicator( + onRefresh: () async => + ref.refresh(adminQuarantineProvider.future), + child: ListView.builder( + itemCount: rows.length, + itemBuilder: (_, i) => AdminQuarantineRow( + key: Key('admin_quarantine_row_${rows[i].trackId}'), + item: rows[i], + onResolve: () => notifier.resolve(rows[i].trackId), + onDeleteFile: () => notifier.deleteFile(rows[i].trackId), + onDeleteViaLidarr: () => + notifier.deleteViaLidarr(rows[i].trackId), + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/admin_quarantine_row.dart b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart new file mode 100644 index 00000000..6e81e7e7 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_quarantine_row.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../models/admin_quarantine_item.dart'; +import '../../theme/theme_extension.dart'; +import 'typed_confirm_sheet.dart'; + +class AdminQuarantineRow extends StatelessWidget { + const AdminQuarantineRow({ + super.key, + required this.item, + required this.onResolve, + required this.onDeleteFile, + required this.onDeleteViaLidarr, + }); + + final AdminQuarantineItem item; + final VoidCallback onResolve; + final VoidCallback onDeleteFile; + final VoidCallback onDeleteViaLidarr; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return ExpansionTile( + key: Key('admin_quarantine_tile_${item.trackId}'), + title: Text( + item.trackTitle, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 16, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${item.artistName} · ${item.albumTitle}', + style: TextStyle(color: fs.ash)), + Text( + '${item.reportCount} ' + '${item.reportCount == 1 ? "report" : "reports"} · ${item.topReasonSummary}', + style: TextStyle(color: fs.error, fontSize: 12), + ), + ], + ), + trailing: PopupMenuButton( + key: Key('admin_quarantine_menu_${item.trackId}'), + icon: Icon(Icons.more_vert, color: fs.parchment), + onSelected: (action) async { + switch (action) { + case 'resolve': + onResolve(); + break; + case 'delete_file': + if (await TypedConfirmSheet.show( + context, + title: 'Delete file?', + message: + 'Permanently delete the local file for "${item.trackTitle}". ' + 'Cannot be undone.', + )) { + onDeleteFile(); + } + break; + case 'delete_lidarr': + if (await TypedConfirmSheet.show( + context, + title: 'Delete via Lidarr?', + message: + 'Ask Lidarr to delete the file for "${item.trackTitle}".', + )) { + onDeleteViaLidarr(); + } + break; + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'resolve', child: Text('Resolve')), + PopupMenuItem(value: 'delete_file', child: Text('Delete file')), + PopupMenuItem( + value: 'delete_lidarr', child: Text('Delete via Lidarr')), + ], + ), + childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + children: [ + for (final report in item.reports) + ListTile( + dense: true, + title: Text( + '${report.username} — ${report.reason}', + style: TextStyle(color: fs.parchment, fontSize: 13), + ), + subtitle: report.notes != null + ? Text(report.notes!, + style: TextStyle(color: fs.ash, fontSize: 12)) + : null, + ), + ], + ); + } +} diff --git a/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart new file mode 100644 index 00000000..2904e7ae --- /dev/null +++ b/flutter_client/lib/admin/widgets/typed_confirm_sheet.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../../theme/theme_extension.dart'; + +/// Modal bottom sheet that requires the user to type [confirmWord] +/// (default "DELETE") before the confirm button enables. Used for +/// destructive actions like quarantine delete-file / delete-via-Lidarr +/// and user delete. +/// +/// Returns true if the user confirmed, false (or null → false) otherwise. +class TypedConfirmSheet extends StatefulWidget { + const TypedConfirmSheet({ + super.key, + required this.title, + required this.message, + this.confirmWord = 'DELETE', + this.confirmLabel = 'Delete', + }); + + final String title; + final String message; + final String confirmWord; + final String confirmLabel; + + static Future show( + BuildContext context, { + required String title, + required String message, + String confirmWord = 'DELETE', + String confirmLabel = 'Delete', + }) async { + final ok = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => TypedConfirmSheet( + title: title, + message: message, + confirmWord: confirmWord, + confirmLabel: confirmLabel, + ), + ); + return ok ?? false; + } + + @override + State createState() => _TypedConfirmSheetState(); +} + +class _TypedConfirmSheetState extends State { + final _controller = TextEditingController(); + bool _enabled = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + color: fs.iron, + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 20, + ), + ), + const SizedBox(height: 12), + Text(widget.message, style: TextStyle(color: fs.ash)), + const SizedBox(height: 16), + Text( + 'Type ${widget.confirmWord} to confirm:', + style: TextStyle(color: fs.ash), + ), + const SizedBox(height: 8), + TextField( + key: const Key('typed_confirm_input'), + controller: _controller, + autofocus: true, + style: TextStyle(color: fs.parchment, fontFamily: 'JetBrainsMono'), + decoration: const InputDecoration(border: OutlineInputBorder()), + onChanged: (v) => + setState(() => _enabled = v == widget.confirmWord), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + ElevatedButton( + key: const Key('typed_confirm_button'), + onPressed: + _enabled ? () => Navigator.pop(context, true) : null, + style: ElevatedButton.styleFrom( + backgroundColor: fs.oxblood, + foregroundColor: fs.parchment, + ), + child: Text(widget.confirmLabel), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_quarantine_screen_test.dart b/flutter_client/test/admin/admin_quarantine_screen_test.dart new file mode 100644 index 00000000..2ec85b9e --- /dev/null +++ b/flutter_client/test/admin/admin_quarantine_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_quarantine_screen.dart'; +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/models/admin_quarantine_item.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 _StubQuarantine extends AdminQuarantineController { + _StubQuarantine(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +const _item = AdminQuarantineItem( + trackId: 't1', + trackTitle: 'Bad Track', + artistName: 'Some Artist', + albumTitle: 'Some Album', + albumId: 'al1', + lidarrAlbumMbid: null, + reportCount: 2, + latestAt: '2026-05-08T00:00:00Z', + reasonCounts: {'wrong_tags': 1, 'bad_rip': 1}, + reports: [ + AdminQuarantineReport( + userId: 'u2', + username: 'alice', + reason: 'wrong_tags', + notes: null, + createdAt: '2026-05-08T00:00:00Z', + ), + AdminQuarantineReport( + userId: 'u3', + username: 'bob', + reason: 'bad_rip', + notes: 'cracks at 2:13', + createdAt: '2026-05-08T00:01:00Z', + ), + ], +); + +Widget _harness(List rows) => ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth()), + adminQuarantineProvider.overrideWith(() => _StubQuarantine(rows)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: const AdminQuarantineScreen(), + ), + ); + +void main() { + testWidgets('empty state', (t) async { + await t.pumpWidget(_harness(const [])); + await t.pumpAndSettle(); + expect(find.text('No quarantined tracks.'), findsOneWidget); + }); + + testWidgets('row renders aggregate summary + 3-dot menu has three actions', + (t) async { + await t.pumpWidget(_harness(const [_item])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('admin_quarantine_tile_t1')), findsOneWidget); + expect(find.text('Bad Track'), findsOneWidget); + expect(find.textContaining('2 reports'), findsOneWidget); + await t.tap(find.byKey(const Key('admin_quarantine_menu_t1'))); + await t.pumpAndSettle(); + expect(find.text('Resolve'), findsOneWidget); + expect(find.text('Delete file'), findsOneWidget); + expect(find.text('Delete via Lidarr'), findsOneWidget); + }); + + testWidgets('expanding tile reveals per-user reports', (t) async { + await t.pumpWidget(_harness(const [_item])); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('admin_quarantine_tile_t1'))); + await t.pumpAndSettle(); + expect(find.textContaining('alice — wrong_tags'), findsOneWidget); + expect(find.textContaining('bob — bad_rip'), findsOneWidget); + expect(find.text('cracks at 2:13'), findsOneWidget); + }); +} From 7d207e56f5584a9542ccc326f467b750b2aebeb7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:56:34 -0400 Subject: [PATCH 13/16] feat(flutter/admin): users + invites mutations Extends AdminUsersController (build-only since slice 2) with setAdmin, setAutoApprove, delete, resetPassword. is_admin/auto_approve toggles optimistically swap the row and roll back on server error (including the last-admin guard 4xx). resetPassword is admin-supplied; no auto-generation mode exists server-side. Adds AdminInvitesController + adminInvitesProvider with create (prepends new invite to the list, returns it for the copy-once dialog) and revoke (optimistic remove + rollback). Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_providers.dart | 101 +++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/flutter_client/lib/admin/admin_providers.dart b/flutter_client/lib/admin/admin_providers.dart index 94ffefbe..dcc220e6 100644 --- a/flutter_client/lib/admin/admin_providers.dart +++ b/flutter_client/lib/admin/admin_providers.dart @@ -8,6 +8,7 @@ import '../library/library_providers.dart' show dioProvider; import '../models/admin_quarantine_item.dart'; import '../models/admin_request.dart'; import '../models/admin_user.dart'; +import '../models/invite.dart'; final adminRequestsApiProvider = FutureProvider((ref) async { return AdminRequestsApi(await ref.watch(dioProvider.future)); @@ -92,16 +93,73 @@ 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. +/// Read side originally defined for the Requests screen's +/// requester-UUID → username join. Mutation methods (setAdmin, +/// setAutoApprove, resetPassword, delete) added alongside the Users +/// screen in Slice 3. resetPassword is admin-supplies; the server +/// has no auto-generation mode. class AdminUsersController extends AsyncNotifier> { @override Future> build() async { final api = await ref.watch(adminUsersApiProvider.future); return api.list(); } + + /// Optimistic flip of `is_admin` on the user row; rolls back on + /// server error (including the last-admin guard 4xx). + Future setAdmin(String id, bool isAdmin) => + _patch(id, (u) => _copy(u, isAdmin: isAdmin), + (api) => api.setAdmin(id, isAdmin)); + + Future setAutoApprove(String id, bool autoApprove) => + _patch(id, (u) => _copy(u, autoApproveRequests: autoApprove), + (api) => api.setAutoApprove(id, autoApprove)); + + Future delete(String id) async { + final api = await ref.read(adminUsersApiProvider.future); + final current = state.value ?? const []; + state = AsyncData(current.where((u) => u.id != id).toList()); + try { + await api.delete(id); + ref.invalidate(adminCountsProvider); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } + + Future resetPassword(String id, String newPassword) async { + final api = await ref.read(adminUsersApiProvider.future); + await api.resetPassword(id, newPassword); + } + + Future _patch( + String id, + AdminUser Function(AdminUser) mutate, + Future Function(AdminUsersApi api) action, + ) async { + final api = await ref.read(adminUsersApiProvider.future); + final current = state.value ?? const []; + state = AsyncData([ + for (final u in current) u.id == id ? mutate(u) : u, + ]); + try { + await action(api); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } + + AdminUser _copy(AdminUser u, {bool? isAdmin, bool? autoApproveRequests}) => + AdminUser( + id: u.id, + username: u.username, + displayName: u.displayName, + isAdmin: isAdmin ?? u.isAdmin, + autoApproveRequests: autoApproveRequests ?? u.autoApproveRequests, + createdAt: u.createdAt, + ); } final adminUsersProvider = @@ -142,3 +200,38 @@ class AdminQuarantineController final adminQuarantineProvider = AsyncNotifierProvider>(AdminQuarantineController.new); + +class AdminInvitesController extends AsyncNotifier> { + @override + Future> build() async { + final api = await ref.watch(adminInvitesApiProvider.future); + return api.list(); + } + + /// Returns the freshly-minted invite so the screen can show the + /// token in a copy-once dialog. The new invite is also prepended + /// to the local list so it shows up without a refresh. + Future create({String? note}) async { + final api = await ref.read(adminInvitesApiProvider.future); + final invite = await api.create(note: note); + final current = state.value ?? const []; + state = AsyncData([invite, ...current]); + return invite; + } + + Future revoke(String token) async { + final api = await ref.read(adminInvitesApiProvider.future); + final current = state.value ?? const []; + state = AsyncData(current.where((i) => i.token != token).toList()); + try { + await api.revoke(token); + } catch (e, st) { + state = AsyncData(current); + Error.throwWithStackTrace(e, st); + } + } +} + +final adminInvitesProvider = + AsyncNotifierProvider>( + AdminInvitesController.new); From afcaf56b9c6d73b9f433b8017d5741b924657250 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:57:10 -0400 Subject: [PATCH 14/16] feat(flutter/admin): user row + edit sheet (toggles, reset, delete) AdminUserRow shows username + admin/auto-approve badges; tap opens AdminUserEditSheet bottom sheet. Edit sheet: SwitchListTile pair for is_admin and auto_approve_requests (both surface server errors via SnackBar, including the last-admin guard). Reset password takes a typed-input dialog (>=8 chars enforced client-side; admin supplies the new password). Delete routes through TypedConfirmSheet ("DELETE") and pops the sheet on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/widgets/admin_user_edit_sheet.dart | 165 ++++++++++++++++++ .../lib/admin/widgets/admin_user_row.dart | 58 ++++++ 2 files changed, 223 insertions(+) create mode 100644 flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart create mode 100644 flutter_client/lib/admin/widgets/admin_user_row.dart diff --git a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart new file mode 100644 index 00000000..7b087c18 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/admin_user.dart'; +import '../../theme/theme_extension.dart'; +import '../admin_providers.dart'; +import 'typed_confirm_sheet.dart'; + +class AdminUserEditSheet extends ConsumerWidget { + const AdminUserEditSheet({super.key, required this.user}); + + final AdminUser user; + + static Future show(BuildContext context, AdminUser user) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => AdminUserEditSheet(user: user), + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final users = ref.read(adminUsersProvider.notifier); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Container( + color: fs.iron, + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.username, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 20, + ), + ), + const SizedBox(height: 12), + SwitchListTile( + key: const Key('user_edit_is_admin'), + title: Text('Admin', style: TextStyle(color: fs.parchment)), + value: user.isAdmin, + onChanged: (v) async { + try { + await users.setAdmin(user.id, v); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + }, + ), + SwitchListTile( + key: const Key('user_edit_auto_approve'), + title: Text('Auto-approve requests', + style: TextStyle(color: fs.parchment)), + value: user.autoApproveRequests, + onChanged: (v) async { + try { + await users.setAutoApprove(user.id, v); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + }, + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton( + key: const Key('user_edit_reset_password'), + onPressed: () => _resetPassword(context, users), + child: const Text('Reset password'), + ), + const Spacer(), + TextButton( + key: const Key('user_edit_delete'), + style: TextButton.styleFrom(foregroundColor: fs.oxblood), + onPressed: () => _deleteUser(context, users), + child: const Text('Delete'), + ), + ], + ), + ], + ), + ), + ); + } + + Future _resetPassword( + BuildContext context, AdminUsersController users) async { + final controller = TextEditingController(); + final newPw = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reset password'), + content: TextField( + controller: controller, + obscureText: true, + decoration: const InputDecoration( + labelText: 'New password', + helperText: 'Minimum 8 characters', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (controller.text.length >= 8) { + Navigator.pop(ctx, controller.text); + } + }, + child: const Text('Reset'), + ), + ], + ), + ); + if (newPw == null || !context.mounted) return; + try { + await users.resetPassword(user.id, newPw); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password reset.')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } + + Future _deleteUser( + BuildContext context, AdminUsersController users) async { + if (!await TypedConfirmSheet.show( + context, + title: 'Delete user?', + message: + 'Permanently delete user "${user.username}" and all their data. ' + 'Cannot be undone.', + )) return; + try { + await users.delete(user.id); + if (context.mounted) Navigator.pop(context); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } +} diff --git a/flutter_client/lib/admin/widgets/admin_user_row.dart b/flutter_client/lib/admin/widgets/admin_user_row.dart new file mode 100644 index 00000000..b029f748 --- /dev/null +++ b/flutter_client/lib/admin/widgets/admin_user_row.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../models/admin_user.dart'; +import '../../theme/theme_extension.dart'; + +class AdminUserRow extends StatelessWidget { + const AdminUserRow({super.key, required this.user, required this.onTap}); + + final AdminUser user; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return ListTile( + title: Text( + user.username, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 16, + ), + ), + subtitle: Wrap( + spacing: 6, + children: [ + if (user.isAdmin) _Badge(label: 'admin', color: fs.bronze), + if (user.autoApproveRequests) + _Badge(label: 'auto-approve', color: fs.moss), + ], + ), + trailing: Icon(Icons.chevron_right, color: fs.ash), + onTap: onTap, + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.label, required this.color}); + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: TextStyle(color: fs.obsidian, fontSize: 11), + ), + ); + } +} From 9f6ceb1731226f181f9aa610bc4951194055c4d5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:57:53 -0400 Subject: [PATCH 15/16] feat(flutter/admin): users screen with users + invites sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-scroll layout with two _SectionHeader-anchored blocks. Users rows tap into AdminUserEditSheet. Invites row shows token (monospace) with copy button, optional note, and a redeemed badge if applicable. "Generate invite" opens a small dialog with an optional note field (server hardcodes 24h TTL — admins can't customise expiry). On success the new token is shown in a copy-once result dialog. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/admin/admin_users_screen.dart | 200 ++++++++++++++++++ .../lib/admin/widgets/invite_row.dart | 72 +++++++ .../test/admin/admin_users_screen_test.dart | 82 +++++++ 3 files changed, 354 insertions(+) create mode 100644 flutter_client/lib/admin/admin_users_screen.dart create mode 100644 flutter_client/lib/admin/widgets/invite_row.dart create mode 100644 flutter_client/test/admin/admin_users_screen_test.dart diff --git a/flutter_client/lib/admin/admin_users_screen.dart b/flutter_client/lib/admin/admin_users_screen.dart new file mode 100644 index 00000000..d12226e2 --- /dev/null +++ b/flutter_client/lib/admin/admin_users_screen.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/widgets/main_app_bar_actions.dart'; +import '../theme/theme_extension.dart'; +import 'admin_providers.dart'; +import 'widgets/admin_user_edit_sheet.dart'; +import 'widgets/admin_user_row.dart'; +import 'widgets/invite_row.dart'; + +class AdminUsersScreen extends ConsumerWidget { + const AdminUsersScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final users = ref.watch(adminUsersProvider); + final invites = ref.watch(adminInvitesProvider); + return Scaffold( + backgroundColor: fs.obsidian, + appBar: AppBar( + backgroundColor: fs.obsidian, + elevation: 0, + title: Text('Users', style: TextStyle(color: fs.parchment)), + actions: const [MainAppBarActions(currentRoute: '/admin/users')], + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(adminUsersProvider); + ref.invalidate(adminInvitesProvider); + }, + child: ListView( + children: [ + const _SectionHeader(text: 'Users'), + users.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (rows) => Column( + children: rows + .map((u) => AdminUserRow( + key: Key('admin_user_row_${u.id}'), + user: u, + onTap: () => AdminUserEditSheet.show(context, u), + )) + .toList(), + ), + ), + const Divider(), + _SectionHeader( + text: 'Invites', + trailing: TextButton.icon( + key: const Key('invite_generate_button'), + onPressed: () => _showGenerateInvite(context, ref), + icon: Icon(Icons.add, color: fs.parchment), + label: Text('Generate', + style: TextStyle(color: fs.parchment)), + ), + ), + invites.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('$e', style: TextStyle(color: fs.error)), + ), + data: (rows) => Column( + children: rows + .map((i) => InviteRow( + key: Key('invite_row_${i.token}'), + invite: i, + onRevoke: () => ref + .read(adminInvitesProvider.notifier) + .revoke(i.token), + )) + .toList(), + ), + ), + ], + ), + ), + ), + ); + } + + Future _showGenerateInvite(BuildContext context, WidgetRef ref) async { + final controller = TextEditingController(); + final note = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Generate invite'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Token expires in 24 hours.'), + const SizedBox(height: 12), + TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Note (optional)', + helperText: 'e.g. "for alice"', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text), + child: const Text('Generate'), + ), + ], + ), + ); + if (note == null || !context.mounted) return; + try { + final invite = await ref + .read(adminInvitesProvider.notifier) + .create(note: note.isEmpty ? null : note); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Invite created'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Share this token with the new user:'), + const SizedBox(height: 8), + SelectableText( + invite.token, + style: const TextStyle(fontFamily: 'JetBrainsMono'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: invite.token)); + Navigator.pop(ctx); + }, + child: const Text('Copy'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('$e'))); + } + } + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.text, this.trailing}); + final String text; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + text, + style: TextStyle( + color: fs.parchment, + fontFamily: 'Fraunces', + fontSize: 18, + ), + ), + const Spacer(), + if (trailing != null) trailing!, + ], + ), + ); + } +} diff --git a/flutter_client/lib/admin/widgets/invite_row.dart b/flutter_client/lib/admin/widgets/invite_row.dart new file mode 100644 index 00000000..6b74a6a5 --- /dev/null +++ b/flutter_client/lib/admin/widgets/invite_row.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../models/invite.dart'; +import '../../theme/theme_extension.dart'; + +class InviteRow extends StatelessWidget { + const InviteRow({ + super.key, + required this.invite, + required this.onRevoke, + }); + + final Invite invite; + final VoidCallback onRevoke; + + @override + Widget build(BuildContext context) { + final fs = Theme.of(context).extension()!; + return ListTile( + title: Row( + children: [ + Expanded( + child: SelectableText( + invite.token, + style: TextStyle( + color: fs.parchment, + fontFamily: 'JetBrainsMono', + fontSize: 13, + ), + ), + ), + IconButton( + icon: Icon(Icons.copy, color: fs.ash, size: 18), + tooltip: 'Copy', + onPressed: () => + Clipboard.setData(ClipboardData(text: invite.token)), + ), + ], + ), + subtitle: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text('expires ${invite.expiresAt}', + style: TextStyle(color: fs.ash, fontSize: 12)), + if (invite.note != null) + Text('· ${invite.note}', + style: TextStyle(color: fs.ash, fontSize: 12)), + if (invite.isRedeemed) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: fs.ash, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'redeemed', + style: TextStyle(color: fs.obsidian, fontSize: 10), + ), + ), + ], + ), + trailing: IconButton( + icon: Icon(Icons.delete_outline, color: fs.oxblood), + tooltip: 'Revoke', + onPressed: onRevoke, + ), + ); + } +} diff --git a/flutter_client/test/admin/admin_users_screen_test.dart b/flutter_client/test/admin/admin_users_screen_test.dart new file mode 100644 index 00000000..ff296b05 --- /dev/null +++ b/flutter_client/test/admin/admin_users_screen_test.dart @@ -0,0 +1,82 @@ +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_users_screen.dart'; +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/models/admin_user.dart'; +import 'package:minstrel/models/invite.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 _StubUsers extends AdminUsersController { + _StubUsers(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +class _StubInvites extends AdminInvitesController { + _StubInvites(this._initial); + final List _initial; + @override + Future> build() async => _initial; +} + +const _userRow = AdminUser( + id: 'u2', + username: 'alice', + displayName: null, + isAdmin: false, + autoApproveRequests: true, + createdAt: '2026-05-01T00:00:00Z', +); + +const _inviteRow = Invite( + token: 'INV-ABC123', + invitedBy: 'u1', + note: 'for alice', + createdAt: '2026-05-08T00:00:00Z', + expiresAt: '2026-05-09T00:00:00Z', +); + +Widget _harness({ + required List users, + required List invites, +}) => + ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth()), + adminUsersProvider.overrideWith(() => _StubUsers(users)), + adminInvitesProvider.overrideWith(() => _StubInvites(invites)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: const AdminUsersScreen(), + ), + ); + +void main() { + testWidgets('renders user rows with badges', (t) async { + await t.pumpWidget(_harness(users: const [_userRow], invites: const [])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('admin_user_row_u2')), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + expect(find.text('auto-approve'), findsOneWidget); + }); + + testWidgets('renders invite rows + Generate button', (t) async { + await t.pumpWidget(_harness(users: const [], invites: const [_inviteRow])); + await t.pumpAndSettle(); + expect(find.byKey(const Key('invite_row_INV-ABC123')), findsOneWidget); + expect(find.byKey(const Key('invite_generate_button')), findsOneWidget); + expect(find.textContaining('for alice'), findsOneWidget); + }); +} From d5c8d316c599757a3ded6a5a826f5528144d9380 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 22:02:14 -0400 Subject: [PATCH 16/16] fix(flutter): clear analyze --fatal-infos errors from admin parity slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught six issues against the new admin slice: - AsyncValue in this Riverpod 3.3.1 codebase exposes `.value` (returns T?), not `.valueOrNull`. Switched the three admin readers to match the established convention (player_bar, queue_screen, now_playing_screen all use `.value`). - home_screen.dart still has ctx.push calls in _albumsRow / _artistsRow (carousel tap handlers) — restored the go_router import that Task 2 over-eagerly removed. - admin_user_edit_sheet.dart had a single-line `if (!await ...) return;` that violated curly_braces_in_flow_control_structures. Wrapped in braces. - admin_quarantine_item.dart doc comment had `` placeholders that the analyzer flagged as unintended HTML. Rephrased without angle brackets. Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/admin/admin_requests_screen.dart | 2 +- flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart | 4 +++- flutter_client/lib/library/home_screen.dart | 1 + flutter_client/lib/models/admin_quarantine_item.dart | 6 +++--- flutter_client/lib/settings/settings_screen.dart | 2 +- flutter_client/lib/shared/widgets/main_app_bar_actions.dart | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/flutter_client/lib/admin/admin_requests_screen.dart b/flutter_client/lib/admin/admin_requests_screen.dart index 0c5acbaa..b0faecf6 100644 --- a/flutter_client/lib/admin/admin_requests_screen.dart +++ b/flutter_client/lib/admin/admin_requests_screen.dart @@ -17,7 +17,7 @@ class AdminRequestsScreen extends ConsumerWidget { // 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 users = ref.watch(adminUsersProvider).value ?? const []; final usersById = {for (final u in users) u.id: u}; return Scaffold( diff --git a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart index 7b087c18..870d4af4 100644 --- a/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart +++ b/flutter_client/lib/admin/widgets/admin_user_edit_sheet.dart @@ -151,7 +151,9 @@ class AdminUserEditSheet extends ConsumerWidget { message: 'Permanently delete user "${user.username}" and all their data. ' 'Cannot be undone.', - )) return; + )) { + return; + } try { await users.delete(user.id); if (context.mounted) Navigator.pop(context); diff --git a/flutter_client/lib/library/home_screen.dart b/flutter_client/lib/library/home_screen.dart index 3c4c404f..87c00645 100644 --- a/flutter_client/lib/library/home_screen.dart +++ b/flutter_client/lib/library/home_screen.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../api/errors.dart'; import '../models/album.dart'; diff --git a/flutter_client/lib/models/admin_quarantine_item.dart b/flutter_client/lib/models/admin_quarantine_item.dart index db1aed39..a27599a7 100644 --- a/flutter_client/lib/models/admin_quarantine_item.dart +++ b/flutter_client/lib/models/admin_quarantine_item.dart @@ -55,9 +55,9 @@ class AdminQuarantineItem { final List reports; - /// One-line summary of the most-cited reason. Returns "" when - /// only one reason exists or " (+N more)" when there are - /// additional distinct reasons. + /// One-line summary of the most-cited reason. Returns just the top + /// reason when only one reason exists, or `top (+N more)` when there + /// are additional distinct reasons. String get topReasonSummary { if (reasonCounts.isEmpty) return ''; final entries = reasonCounts.entries.toList() diff --git a/flutter_client/lib/settings/settings_screen.dart b/flutter_client/lib/settings/settings_screen.dart index 02ce6553..9b2786b7 100644 --- a/flutter_client/lib/settings/settings_screen.dart +++ b/flutter_client/lib/settings/settings_screen.dart @@ -97,7 +97,7 @@ class _AdminSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final profile = ref.watch(_profileProvider).valueOrNull; + final profile = ref.watch(_profileProvider).value; if (profile == null || !profile.isAdmin) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart index eedc72a4..e0eab4a8 100644 --- a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart +++ b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart @@ -17,7 +17,7 @@ class MainAppBarActions extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final fs = Theme.of(context).extension()!; - final user = ref.watch(authControllerProvider).valueOrNull; + final user = ref.watch(authControllerProvider).value; final isAdmin = user?.isAdmin ?? false; return Row(