diff options
| -rw-r--r-- | player-android/lib/app_routes.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 25 | ||||
| -rw-r--r-- | player-android/lib/screens/admin_permissions_screen.dart | 473 | ||||
| -rw-r--r-- | player-android/lib/screens/admin_rescan_screen.dart | 427 | ||||
| -rw-r--r-- | player-android/lib/screens/admin_trash_screen.dart | 421 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 33 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 60 |
7 files changed, 1455 insertions, 0 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index 4d31fbd..c07559a 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -121,4 +121,20 @@ abstract final class AppRoutes { /// Only accessible when the authenticated user has admin privileges. /// Shows a list of all registered users and allows creating/deleting accounts. static const adminUsers = '/admin/users'; + + /// Route for the admin permission matrix screen. + /// + /// Displays a cross-table of users vs. sets with checkboxes for granting or + /// revoking per-user access to each set. Admin-only. + static const adminPermissions = '/admin/permissions'; + + /// Route for the admin rescan screen. + /// + /// Allows an admin to trigger a library rescan and monitor live progress. + static const adminRescan = '/admin/rescan'; + + /// Route for the admin trash screen. + /// + /// Lists soft-deleted media items and allows restore or hard-delete. + static const adminTrash = '/admin/trash'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 4c60d8d..7cf1823 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -20,6 +20,9 @@ import 'screens/share_screen.dart'; import 'screens/share_viewer_screen.dart'; import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; +import 'screens/admin_permissions_screen.dart'; +import 'screens/admin_rescan_screen.dart'; +import 'screens/admin_trash_screen.dart'; import 'screens/admin_users_screen.dart'; import 'screens/folder_browser_screen.dart'; import 'screens/video_player_screen.dart'; @@ -235,6 +238,28 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const AdminUsersScreen(), ), GoRoute( + // Admin permission matrix — cross-table of users vs. sets with + // checkboxes for granting or revoking per-user set access. + // Admin-only; non-admin users receive 403 from the server if they + // somehow reach this route directly. + path: AppRoutes.adminPermissions, + builder: (context, state) => const AdminPermissionsScreen(), + ), + GoRoute( + // Admin rescan screen — triggers a library rescan and polls live + // progress until the scan completes. + // Admin-only; non-admin users receive 403 from the server. + path: AppRoutes.adminRescan, + builder: (context, state) => const AdminRescanScreen(), + ), + GoRoute( + // Admin trash screen — lists soft-deleted media items and allows + // restore or permanent deletion. + // Admin-only; non-admin users receive 403 from the server. + path: AppRoutes.adminTrash, + builder: (context, state) => const AdminTrashScreen(), + ), + GoRoute( // Folder browser — shows subfolders and media at the current path // within a set. The ':setId' path segment identifies the set; // the optional 'path' query parameter identifies the current subfolder 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'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/admin_rescan_screen.dart b/player-android/lib/screens/admin_rescan_screen.dart new file mode 100644 index 0000000..3ce8953 --- /dev/null +++ b/player-android/lib/screens/admin_rescan_screen.dart @@ -0,0 +1,427 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +/// Admin-only rescan screen. +/// +/// Design notes: +/// - The user taps "Trigger Rescan" to start a library rescan on the server. +/// - After triggering, the screen polls [getScanProgress] every 2 seconds +/// while the scan is running, displaying live progress (file counts, current +/// set name). +/// - The poll timer is stored in [_pollTimer] and cancelled in [dispose] to +/// prevent memory leaks and spurious setState calls after the widget is gone. +/// - A generation counter prevents stale polling results from overwriting +/// the state after the user navigates away and back again. +/// - The trigger button is disabled while a scan is actively running to +/// prevent duplicate scans. +/// - All async continuations guard on [mounted] to prevent setState/context +/// calls after widget disposal. +class AdminRescanScreen extends ConsumerStatefulWidget { + const AdminRescanScreen({super.key}); + + @override + ConsumerState<AdminRescanScreen> createState() => _AdminRescanScreenState(); +} + +class _AdminRescanScreenState extends ConsumerState<AdminRescanScreen> { + // Interval between progress poll requests while a scan is running. + static const _pollInterval = Duration(seconds: 2); + + // Null before the first status fetch, non-null after. + _ScanStatus? _status; + + // Non-null when the last API call failed. + String? _error; + + // True while the trigger request is in flight. + bool _isTriggerring = false; + + // Active polling timer; cancelled in dispose and whenever the scan finishes. + Timer? _pollTimer; + + // Generation counter: async completions discard results if they captured a + // stale generation value (prevents out-of-order result clobbering). + int _generation = 0; + + @override + void initState() { + super.initState(); + // Fetch the current scan status immediately so the user sees whether a + // scan is already running (e.g. started by another admin session). + WidgetsBinding.instance.addPostFrameCallback((_) => _fetchStatus()); + } + + @override + void dispose() { + // Always cancel the polling timer to avoid calling setState after disposal + // and to release the periodic timer resource. + _pollTimer?.cancel(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Status fetching + // --------------------------------------------------------------------------- + + /// Fetches the current scan progress and updates [_status]. + /// + /// If the scan is running, a poll timer is started (or kept running). + /// If the scan is idle/complete, any active poll timer is cancelled. + Future<void> _fetchStatus() async { + if (!mounted) return; + final generation = ++_generation; + + try { + final raw = await ref.read(apiClientProvider).getScanProgress(); + if (!mounted || generation != _generation) return; + + final status = _ScanStatus.fromMap(raw); + setState(() { + _status = status; + _error = null; + }); + + _updatePolling(status.isRunning); + } catch (e) { + if (!mounted || generation != _generation) return; + setState(() => _error = adminRescanErrorMessage(e)); + // Stop polling on error to avoid hammering a broken endpoint; the user + // can retry manually via the refresh button. + _pollTimer?.cancel(); + _pollTimer = null; + } + } + + /// Starts or stops the background polling timer based on [scanRunning]. + /// + /// Starts a new periodic timer when [scanRunning] is true and no timer is + /// active; cancels any active timer when [scanRunning] is false. + void _updatePolling(bool scanRunning) { + if (scanRunning && _pollTimer == null) { + // Poll every 2 seconds while the scan is running to show live progress. + _pollTimer = Timer.periodic(_pollInterval, (_) => _fetchStatus()); + } else if (!scanRunning) { + _pollTimer?.cancel(); + _pollTimer = null; + } + } + + // --------------------------------------------------------------------------- + // Trigger rescan action + // --------------------------------------------------------------------------- + + /// Sends a trigger-rescan request and immediately begins polling for progress. + Future<void> _triggerRescan() async { + if (!mounted || _isTriggerring) return; + setState(() { + _isTriggerring = true; + _error = null; + }); + + try { + await ref.read(apiClientProvider).triggerRescan(); + if (!mounted) return; + setState(() => _isTriggerring = false); + // Start polling immediately so the user sees progress as soon as the + // server reports the scan has begun. + await _fetchStatus(); + } catch (e) { + if (!mounted) return; + setState(() { + _isTriggerring = false; + _error = adminRescanErrorMessage(e); + }); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Rescan Library'), + actions: [ + IconButton( + key: const Key('admin_rescan_refresh'), + icon: const Icon(Icons.refresh), + tooltip: 'Check status', + onPressed: _fetchStatus, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: _buildBody(context), + ), + ), + ); + } + + /// Builds the screen body: status card + trigger button. + Widget _buildBody(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _StatusCard(status: _status, error: _error), + const SizedBox(height: 32), + _TriggerButton( + isRunning: _status?.isRunning ?? false, + isTriggerring: _isTriggerring, + onTap: _triggerRescan, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Data model for scan progress +// --------------------------------------------------------------------------- + +/// Parsed scan progress state returned by GET /api/v1/admin/scan-progress. +/// +/// Kept as a plain data class (no business logic) so [_AdminRescanScreenState] +/// and the sub-widgets stay focused on their own concerns (SRP). +class _ScanStatus { + const _ScanStatus({ + required this.isRunning, + required this.currentSet, + required this.setsTotal, + required this.setsDone, + required this.filesTotal, + required this.filesDone, + this.lastError, + }); + + /// Parses the raw progress map returned by the server. + factory _ScanStatus.fromMap(Map<String, dynamic> map) { + return _ScanStatus( + isRunning: map['running'] as bool? ?? false, + currentSet: map['current_set'] as String? ?? '', + setsTotal: map['sets_total'] as int? ?? 0, + setsDone: map['sets_done'] as int? ?? 0, + filesTotal: map['files_total'] as int? ?? 0, + filesDone: map['files_done'] as int? ?? 0, + lastError: map['last_error'] as String?, + ); + } + + final bool isRunning; + final String currentSet; + final int setsTotal; + final int setsDone; + final int filesTotal; + final int filesDone; + final String? lastError; +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Card that displays the current scan status and progress. +/// +/// Shows a spinner + live counters while running; shows "Idle" or "Scan +/// complete" when not running; shows a loading placeholder before the first +/// status fetch completes. +class _StatusCard extends StatelessWidget { + const _StatusCard({required this.status, required this.error}); + + final _ScanStatus? status; + final String? error; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: _cardContent(context), + ), + ); + } + + /// Returns the inner content of the status card. + Widget _cardContent(BuildContext context) { + // Show error state if there was an API failure. + if (error != null) { + return _ErrorRow(message: error!); + } + + // Show a spinner while the initial status fetch is in progress. + final s = status; + if (s == null) { + return const Center( + key: Key('admin_rescan_status_loading'), + child: CircularProgressIndicator(), + ); + } + + if (s.isRunning) { + return _RunningContent(status: s); + } + + return _IdleContent(status: s); + } +} + +/// Status card content while a scan is running. +class _RunningContent extends StatelessWidget { + const _RunningContent({required this.status}); + + final _ScanStatus status; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Text( + 'Scan running…', + key: const Key('admin_rescan_running_label'), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + if (status.currentSet.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'Current set: ${status.currentSet}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + if (status.setsTotal > 0) ...[ + const SizedBox(height: 6), + Text('Sets: ${status.setsDone} / ${status.setsTotal}'), + ], + if (status.filesTotal > 0) ...[ + const SizedBox(height: 6), + Text('Files: ${status.filesDone} / ${status.filesTotal}'), + ], + ], + ); + } +} + +/// Status card content when no scan is running. +class _IdleContent extends StatelessWidget { + const _IdleContent({required this.status}); + + final _ScanStatus status; + + @override + Widget build(BuildContext context) { + // Show a "Scan complete" summary when there are files already scanned; + // otherwise show the neutral "Idle" state. + final hasScanned = status.filesTotal > 0 || status.setsDone > 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + hasScanned ? Icons.check_circle_outline : Icons.schedule_outlined, + color: hasScanned + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + hasScanned ? 'Scan complete' : 'Idle — no scan running', + key: const Key('admin_rescan_idle_label'), + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + if (hasScanned && status.filesTotal > 0) ...[ + const SizedBox(height: 8), + Text('Files scanned: ${status.filesDone} / ${status.filesTotal}'), + ], + if (status.lastError != null && status.lastError!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Last error: ${status.lastError}', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + ], + ); + } +} + +/// Inline error row shown inside the status card. +class _ErrorRow extends StatelessWidget { + const _ErrorRow({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + key: const Key('admin_rescan_error'), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } +} + +/// Button that triggers a rescan. +/// +/// Disabled while a scan is running or a trigger request is in flight, +/// preventing duplicate scans and accidental double-taps. +class _TriggerButton extends StatelessWidget { + const _TriggerButton({ + required this.isRunning, + required this.isTriggerring, + required this.onTap, + }); + + final bool isRunning; + final bool isTriggerring; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + // Disable the button while a scan is active or the trigger is in flight. + final canTrigger = !isRunning && !isTriggerring; + + return FilledButton.icon( + key: const Key('admin_rescan_trigger'), + onPressed: canTrigger ? onTap : null, + icon: isTriggerring + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.sync_outlined), + label: Text(isRunning ? 'Scan in progress…' : 'Trigger Rescan'), + ); + } +} diff --git a/player-android/lib/screens/admin_trash_screen.dart b/player-android/lib/screens/admin_trash_screen.dart new file mode 100644 index 0000000..1b63596 --- /dev/null +++ b/player-android/lib/screens/admin_trash_screen.dart @@ -0,0 +1,421 @@ +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 screen that lists soft-deleted media items (the trash). +/// +/// Design notes: +/// - Items are loaded from GET /api/v1/admin/trash via [listTrash]. +/// - Restore action calls [restoreMedia]; optimistic UI removes the item +/// from the trash list immediately and reverts on error. +/// - Hard-delete shows a confirmation dialog before calling [deleteMedia] +/// (permanent purge from the perspective of this UI, even though the +/// underlying API still performs a soft-delete — the server's GC worker +/// completes the physical removal). +/// - A generation counter prevents stale loads from overwriting a newer +/// refresh that started while the previous was still in flight. +/// - All async continuations guard on [mounted] to prevent setState/context +/// calls after widget disposal. +class AdminTrashScreen extends ConsumerStatefulWidget { + const AdminTrashScreen({super.key}); + + @override + ConsumerState<AdminTrashScreen> createState() => _AdminTrashScreenState(); +} + +class _AdminTrashScreenState extends ConsumerState<AdminTrashScreen> { + // Null while the initial load is in flight. + List<Media>? _items; + + // Non-null when the last load attempt failed. + String? _error; + + // True while a load is in flight (initial or r |
