diff options
Diffstat (limited to 'player-android/lib/screens')
| -rw-r--r-- | player-android/lib/screens/my_shares_screen.dart | 374 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 24 |
2 files changed, 398 insertions, 0 deletions
diff --git a/player-android/lib/screens/my_shares_screen.dart b/player-android/lib/screens/my_shares_screen.dart new file mode 100644 index 0000000..946aee8 --- /dev/null +++ b/player-android/lib/screens/my_shares_screen.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +/// MyShares screen: lists all share links created by the authenticated user. +/// +/// Design notes: +/// - [ConsumerStatefulWidget] is used so local list state can be managed +/// and [WidgetRef] is available for async API calls with [mounted] guards. +/// - The share list is stored locally: [_shares] is null during the initial +/// load (spinner shown), non-null after first successful fetch. +/// - Revoke is optimistic: the share is removed from the list immediately, +/// then the API call is made. On error the item is re-inserted at its +/// original position and an error SnackBar is shown. +/// - Copy-link calls [shareUrl] on the client (no HTTP call) and writes to +/// the clipboard, then shows a confirmation SnackBar. +/// - All async continuations guard on [mounted] to prevent setState/context +/// calls after widget disposal. +class MySharesScreen extends ConsumerStatefulWidget { + const MySharesScreen({super.key}); + + @override + ConsumerState<MySharesScreen> createState() => _MySharesScreenState(); +} + +class _MySharesScreenState extends ConsumerState<MySharesScreen> { + // Null while the initial load is in-flight; empty list when server returns []. + List<Share>? _shares; + + // Non-null when the last load attempt failed. + String? _error; + + // True while the initial or refresh load is in flight. + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Defer first load until after the first frame so provider overrides in + // tests are applied before [ref] is used. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches the user's share list and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Errors are mapped by the + /// top-level [sharesErrorMessage] helper so the widget stays simple. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(apiClientProvider); + final shares = await client.listMyShares(); + if (!mounted) return; + setState(() { + _shares = shares; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = sharesErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Copy-link action + // --------------------------------------------------------------------------- + + /// Copies the public share URL for [share] to the clipboard and shows a + /// confirmation SnackBar. + /// + /// [shareUrl] is a pure URL-builder on [PlayerApiClient] (no HTTP call). + void _copyLink(Share share) { + final client = ref.read(apiClientProvider); + final url = client.shareUrl(share.token); + Clipboard.setData(ClipboardData(text: url)); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('shares_copy_snackbar'), + content: Text('Link copied: $url'), + duration: const Duration(seconds: 4), + ), + ); + } + + // --------------------------------------------------------------------------- + // Revoke action + // --------------------------------------------------------------------------- + + /// Optimistically removes [share] from the list, calls [revokeShare], and + /// reverts on error. + /// + /// The optimistic removal keeps the UI responsive: the row disappears + /// immediately. If the API call fails the item is re-inserted at [index] + /// so the list is consistent with the server state. + Future<void> _revoke(Share share, int index) async { + // Optimistic removal. + setState(() => _shares!.removeAt(index)); + + try { + final client = ref.read(apiClientProvider); + await client.revokeShare(share.token); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + key: Key('shares_revoke_snackbar'), + content: Text('Share revoked.'), + duration: Duration(seconds: 3), + ), + ); + } catch (e) { + // Revert optimistic removal on error. + if (!mounted) return; + setState(() => _shares!.insert(index, share)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + key: const Key('shares_revoke_error_snackbar'), + content: Text(sharesErrorMessage(e)), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('My Shares')), + body: _buildBody(context), + ); + } + + /// Builds the main body, delegating to the appropriate state widget: + /// - Full-screen spinner on the very first load (no data yet). + /// - Error view with a retry button when the load failed. + /// - Pull-to-refresh wrapper around the share list or empty-state view. + Widget _buildBody(BuildContext context) { + if (_isLoading && _shares == null) { + return const Center( + key: Key('shares_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + return RefreshIndicator( + onRefresh: _load, + child: _shares == null || _shares!.isEmpty + ? const _EmptyView() + : _ShareList( + shares: _shares!, + onCopyLink: _copyLink, + onRevoke: _revoke, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable list of [Share] rows. +/// +/// Extracted into its own stateless widget so [_MySharesScreenState] stays +/// focused on data-loading concerns and the list UI is independently testable. +class _ShareList extends StatelessWidget { + const _ShareList({ + required this.shares, + required this.onCopyLink, + required this.onRevoke, + }); + + final List<Share> shares; + final void Function(Share share) onCopyLink; + final Future<void> Function(Share share, int index) onRevoke; + + @override + Widget build(BuildContext context) { + return ListView.separated( + key: const Key('shares_list'), + itemCount: shares.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final share = shares[index]; + return _ShareTile( + share: share, + index: index, + onCopyLink: onCopyLink, + onRevoke: onRevoke, + ); + }, + ); + } +} + +/// A single share row with copy-link and revoke actions. +/// +/// Shows the filename (falling back to media-ID when absent), the expiry date +/// (or "No expiry" for non-expiring shares), and the used/max-uses count. +/// A trailing icon row provides copy-link and revoke buttons. +class _ShareTile extends StatelessWidget { + const _ShareTile({ + required this.share, + required this.index, + required this.onCopyLink, + required this.onRevoke, + }); + + final Share share; + final int index; + final void Function(Share share) onCopyLink; + final Future<void> Function(Share share, int index) onRevoke; + + @override + Widget build(BuildContext context) { + final title = share.fileName ?? 'Media #${share.mediaId}'; + final expiry = _formatExpiry(share.expiresAt); + final uses = share.maxUses != null + ? '${share.usedCount}/${share.maxUses} uses' + : '${share.usedCount} uses'; + + return ListTile( + key: Key('share_tile_${share.token}'), + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text('$expiry · $uses'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Copy-link: writes the share URL to the clipboard. + IconButton( + key: Key('share_copy_${share.token}'), + icon: const Icon(Icons.copy_outlined), + tooltip: 'Copy link', + onPressed: () => onCopyLink(share), + ), + // Revoke: removes the share optimistically. + IconButton( + key: Key('share_revoke_${share.token}'), + icon: const Icon(Icons.delete_outline), + tooltip: 'Revoke share', + onPressed: () => onRevoke(share, index), + ), + ], + ), + ); + } + + /// Formats [expiresAt] as "Expires YYYY-MM-DD" or "No expiry". + /// + /// Kept as a pure static helper so it can be called without a [BuildContext] + /// and is easy to unit-test in isolation. + static String _formatExpiry(DateTime? expiresAt) { + if (expiresAt == null) return 'No expiry'; + final y = expiresAt.year.toString(); + final m = expiresAt.month.toString().padLeft(2, '0'); + final d = expiresAt.day.toString().padLeft(2, '0'); + return 'Expires $y-$m-$d'; + } +} + +/// Full-screen empty-state view shown when [listMyShares] returns []. +/// +/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the parent +/// [RefreshIndicator] can still trigger pull-to-refresh even with no content. +class _EmptyView extends StatelessWidget { + const _EmptyView(); + + @override + Widget build(BuildContext context) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.link_off, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No shares yet', + key: const Key('shares_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Share links you create will appear here.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ); + } +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [listMyShares] throws (network error, server error, etc.). +/// The [message] comes from [sharesErrorMessage], which maps exceptions to +/// human-readable strings. +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('shares_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('shares_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart index 143acd2..be812eb 100644 --- a/player-android/lib/screens/settings_screen.dart +++ b/player-android/lib/screens/settings_screen.dart @@ -207,6 +207,30 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { onPressed: _saveBaseUrl, child: const Text('Save URL'), ), + + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 24), + + // ---------------------------------------------------------------- + // Sharing section: navigate to MyShares screen. + // ---------------------------------------------------------------- + Text( + 'Sharing', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // My Shares tile — navigates to /shares. + ListTile( + key: const Key('settings_my_shares'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.link_outlined), + title: const Text('My Shares'), + subtitle: const Text('View and revoke your share links'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go(AppRoutes.shares), + ), ], ), ), |
