summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/admin_permissions_screen.dart
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 16:43:08 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 16:43:08 +0300
commitd15b31ec2248846af2adede429e7278e374cb4c3 (patch)
treee694aa7248b5d274caedd6837bbf823b0b83147c /player-android/lib/screens/admin_permissions_screen.dart
parent874a305bbc23e9a1e6428b4e87071c3567317545 (diff)
Implement AdminPermissionsScreen, RescanScreen, and TrashScreen (eb)
Add three admin-only screens with routes and navigation tiles: - AdminPermissionsScreen: permission matrix (users × sets) with optimistic grant/revoke checkboxes; admin rows shown as disabled (implicit access). - AdminRescanScreen: trigger library rescan, poll getScanProgress every 2s while running, cancel timer in dispose, show live file/set counters. - AdminTrashScreen: list soft-deleted media with restore + hard-delete (confirmation dialog); optimistic UI with revert-on-error for both actions. All three use generation counters for stale-async cancellation and guard async continuations on mounted. Added error mappers and route constants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/lib/screens/admin_permissions_screen.dart')
-rw-r--r--player-android/lib/screens/admin_permissions_screen.dart473
1 files changed, 473 insertions, 0 deletions
diff --git a/player-android/lib/screens/admin_permissions_screen.dart b/player-android/lib/screens/admin_permissions_screen.dart
new file mode 100644
index 0000000..0c35a1b
--- /dev/null
+++ b/player-android/lib/screens/admin_permissions_screen.dart
@@ -0,0 +1,473 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../models/models.dart';
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
+
+/// Admin-only permission matrix screen.
+///
+/// Design notes:
+/// - Rows = users, columns = sets; each cell is a checkbox indicating whether
+/// that user has access to that set.
+/// - Admin users always have implicit access to all sets (server-enforced);
+/// their rows are rendered as disabled/grayed to make this constraint
+/// visible in the UI without suggesting they can be edited.
+/// - Grant/revoke use optimistic UI: the checkbox is toggled locally first,
+/// then the API call is made. On error the toggle is reverted and a
+/// SnackBar reports the problem.
+/// - A generation counter prevents stale async load results from overwriting
+/// a newer refresh that was started while the previous one was in flight.
+/// - All async continuations guard on [mounted] to prevent setState/context
+/// calls after widget disposal.
+class AdminPermissionsScreen extends ConsumerStatefulWidget {
+ const AdminPermissionsScreen({super.key});
+
+ @override
+ ConsumerState<AdminPermissionsScreen> createState() =>
+ _AdminPermissionsScreenState();
+}
+
+class _AdminPermissionsScreenState
+ extends ConsumerState<AdminPermissionsScreen> {
+ // Null while the initial load is in-flight.
+ List<User>? _users;
+ List<MediaSet>? _sets;
+
+ // Tracks which (userId, setId) pairs have an explicit permission row.
+ // Using a Set<_PermKey> keeps lookup O(1) and avoids scanning a flat list
+ // on every checkbox render (important for larger permission matrices).
+ final Set<_PermKey> _granted = {};
+
+ // Non-null when the last load attempt failed.
+ String? _error;
+
+ // True while the initial or refresh load is in flight.
+ bool _isLoading = false;
+
+ // Incremented on every load; async completions discard results if the
+ // generation they captured no longer matches (stale-async-cancellation).
+ int _generation = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ // Defer until after the first frame so provider overrides in tests apply.
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Data loading
+ // ---------------------------------------------------------------------------
+
+ /// Loads users, sets, and the permission matrix in parallel.
+ ///
+ /// Running the three requests concurrently keeps the UI snappy even on
+ /// connections with higher latency, because none of them depend on each other.
+ Future<void> _load() async {
+ if (!mounted) return;
+ final generation = ++_generation;
+
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final api = ref.read(apiClientProvider);
+ // Fetch users, sets, and permissions concurrently to minimise wait time.
+ final results = await Future.wait([
+ api.listUsers(),
+ api.listSets(),
+ api.listPermissions(),
+ ]);
+
+ if (!mounted || generation != _generation) return;
+
+ final users = results[0] as List<User>;
+ final sets = results[1] as List<MediaSet>;
+ final permsData = results[2] as Map<String, dynamic>;
+
+ setState(() {
+ _users = users;
+ _sets = sets;
+ _granted
+ ..clear()
+ ..addAll(_parsePermissions(permsData));
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted || generation != _generation) return;
+ setState(() {
+ _error = adminPermissionErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ /// Parses the raw permission map returned by [listPermissions] into a flat
+ /// set of (userId, setId) pairs.
+ ///
+ /// The API response has the shape:
+ /// `{"permissions": [{"user_id": 1, "set_id": 2, "role": "viewer"}, ...]}`
+ /// We only care about the existence of a row (not the role) for the checkbox
+ /// state, so we collapse the list to a [Set<_PermKey>].
+ Set<_PermKey> _parsePermissions(Map<String, dynamic> data) {
+ final result = <_PermKey>{};
+ final rawList = data['permissions'];
+ if (rawList is! List) return result;
+ for (final item in rawList) {
+ if (item is! Map<String, dynamic>) continue;
+ final userId = item['user_id'] as int?;
+ final setId = item['set_id'] as int?;
+ if (userId != null && setId != null) {
+ result.add(_PermKey(userId: userId, setId: setId));
+ }
+ }
+ return result;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Grant / revoke actions (optimistic UI)
+ // ---------------------------------------------------------------------------
+
+ /// Toggles the permission for [userId] on [setId].
+ ///
+ /// Applies the change locally first so the UI feels instant, then calls the
+ /// server. On error, the local change is reverted and a SnackBar is shown.
+ Future<void> _toggle(int userId, int setId, bool newValue) async {
+ final key = _PermKey(userId: userId, setId: setId);
+
+ // Optimistic update: reflect the desired state immediately.
+ setState(() {
+ if (newValue) {
+ _granted.add(key);
+ } else {
+ _granted.remove(key);
+ }
+ });
+
+ try {
+ final api = ref.read(apiClientProvider);
+ if (newValue) {
+ // Grant with the default 'viewer' role; promotion to 'owner' is out
+ // of scope for the permission matrix (could be a future enhancement).
+ await api.grantPermission(
+ userId: userId,
+ setId: setId,
+ role: 'viewer',
+ );
+ } else {
+ await api.revokePermission(userId: userId, setId: setId);
+ }
+ } catch (e) {
+ if (!mounted) return;
+ // Revert the optimistic change so the UI reflects the true server state.
+ setState(() {
+ if (newValue) {
+ _granted.remove(key);
+ } else {
+ _granted.add(key);
+ }
+ });
+ _showError(adminPermissionErrorMessage(e));
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Error display
+ // ---------------------------------------------------------------------------
+
+ void _showError(String message) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ key: const Key('admin_perms_error_snackbar'),
+ content: Text(message),
+ backgroundColor: Theme.of(context).colorScheme.error,
+ ),
+ );
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Permissions'),
+ actions: [
+ IconButton(
+ key: const Key('admin_perms_refresh'),
+ icon: const Icon(Icons.refresh),
+ tooltip: 'Refresh',
+ onPressed: _load,
+ ),
+ ],
+ ),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Selects the appropriate body widget for the current state.
+ Widget _buildBody(BuildContext context) {
+ if (_isLoading && _users == null) {
+ return const Center(
+ key: Key('admin_perms_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (_error != null) {
+ return _ErrorView(message: _error!, onRetry: _load);
+ }
+
+ final users = _users;
+ final sets = _sets;
+ if (users == null || sets == null || users.isEmpty || sets.isEmpty) {
+ return const _EmptyView();
+ }
+
+ return RefreshIndicator(
+ onRefresh: _load,
+ child: _PermissionMatrix(
+ users: users,
+ sets: sets,
+ granted: _granted,
+ onToggle: _toggle,
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Value object for a (userId, setId) permission key
+// ---------------------------------------------------------------------------
+
+/// Immutable key that uniquely identifies a permission row.
+///
+/// Used as a Set element so lookup is O(1) per checkbox render.
+/// Equality is structural (both fields must match), matching the server's
+/// composite primary key on the permissions table.
+class _PermKey {
+ const _PermKey({required this.userId, required this.setId});
+
+ final int userId;
+ final int setId;
+
+ @override
+ bool operator ==(Object other) =>
+ other is _PermKey && other.userId == userId && other.setId == setId;
+
+ @override
+ int get hashCode => Object.hash(userId, setId);
+}
+
+// ---------------------------------------------------------------------------
+// Permission matrix widget
+// ---------------------------------------------------------------------------
+
+/// Scrollable permission matrix: rows = users, columns = sets.
+///
+/// Extracted as a stateless widget (SRP) so the state class focuses on
+/// data-loading and mutation, while this widget handles pure rendering.
+class _PermissionMatrix extends StatelessWidget {
+ const _PermissionMatrix({
+ required this.users,
+ required this.sets,
+ required this.granted,
+ required this.onToggle,
+ });
+
+ final List<User> users;
+ final List<MediaSet> sets;
+
+ /// Current permission snapshot; a key present here means the cell is checked.
+ final Set<_PermKey> granted;
+
+ /// Called when the user taps a checkbox. [newValue] is the desired new state.
+ final Future<void> Function(int userId, int setId, bool newValue) onToggle;
+
+ @override
+ Widget build(BuildContext context) {
+ // Horizontal scroll wraps the full table so narrow screens can still see
+ // all set columns without clipping.
+ return SingleChildScrollView(
+ scrollDirection: Axis.vertical,
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: _buildTable(context),
+ ),
+ );
+ }
+
+ /// Builds the DataTable with a header row of set names and user data rows.
+ Widget _buildTable(BuildContext context) {
+ return DataTable(
+ key: const Key('admin_perms_table'),
+ // Each set gets one column; the user column is always first.
+ columns: [
+ const DataColumn(label: Text('User')),
+ ...sets.map(
+ (s) => DataColumn(label: Text(s.name, overflow: TextOverflow.ellipsis)),
+ ),
+ ],
+ rows: users.map((user) => _buildRow(context, user)).toList(),
+ );
+ }
+
+ /// Builds a single user row with one checkbox cell per set.
+ ///
+ /// Admin users have implicit access to all sets (enforced server-side), so
+ /// their checkboxes are shown as disabled to make this constraint obvious
+ /// without implying they can be changed.
+ DataRow _buildRow(BuildContext context, User user) {
+ return DataRow(
+ // Visually dim admin rows to signal that their access cannot be edited.
+ color: user.isAdmin
+ ? WidgetStateProperty.all(
+ Theme.of(context).colorScheme.surfaceContainerHighest,
+ )
+ : null,
+ cells: [
+ // First cell: username + optional "admin" badge.
+ DataCell(_UserCell(user: user)),
+ // One cell per set column.
+ ...sets.map((s) => _buildPermCell(user, s)),
+ ],
+ );
+ }
+
+ /// Builds a single checkbox cell for the (user, set) intersection.
+ DataCell _buildPermCell(User user, MediaSet set) {
+ final key = _PermKey(userId: user.id, setId: set.id);
+ // Admin users always have access; their cells are checked but non-interactive
+ // to reflect the implicit access the server grants them.
+ final isAdmin = user.isAdmin;
+ final isChecked = isAdmin || granted.contains(key);
+
+ return DataCell(
+ Checkbox(
+ key: Key('perm_${user.id}_${set.id}'),
+ value: isChecked,
+ // Disable interaction for admin users; their access is server-managed.
+ onChanged: isAdmin
+ ? null
+ : (value) => onToggle(user.id, set.id, value ?? false),
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// User cell widget
+// ---------------------------------------------------------------------------
+
+/// Displays a user's name and an "Admin" badge when applicable.
+///
+/// Extracted to keep [_PermissionMatrix._buildRow] readable (SRP).
+class _UserCell extends StatelessWidget {
+ const _UserCell({required this.user});
+
+ final User user;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(user.username),
+ if (user.isAdmin) ...[
+ const SizedBox(width: 6),
+ Chip(
+ label: Text(
+ 'Admin',
+ style: TextStyle(
+ fontSize: 11,
+ color: Theme.of(context).colorScheme.onPrimaryContainer,
+ ),
+ ),
+ backgroundColor:
+ Theme.of(context).colorScheme.primaryContainer,
+ padding: EdgeInsets.zero,
+ visualDensity: VisualDensity.compact,
+ ),
+ ],
+ ],
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Shared sub-widgets (empty state, error state)
+// ---------------------------------------------------------------------------
+
+/// Full-screen empty-state shown when there are no users or no sets.
+class _EmptyView extends StatelessWidget {
+ const _EmptyView();
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ Icons.lock_outline,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'No users or sets found',
+ key: const Key('admin_perms_empty'),
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Full-screen error view with a retry button.
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.error_outline,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('admin_perms_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('admin_perms_retry'),
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}