summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/app_routes.dart16
-rw-r--r--player-android/lib/router.dart25
-rw-r--r--player-android/lib/screens/admin_permissions_screen.dart473
-rw-r--r--player-android/lib/screens/admin_rescan_screen.dart427
-rw-r--r--player-android/lib/screens/admin_trash_screen.dart421
-rw-r--r--player-android/lib/screens/settings_screen.dart33
-rw-r--r--player-android/lib/utils/error_mappers.dart60
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