diff options
| -rw-r--r-- | player-android/lib/app_routes.dart | 4 | ||||
| -rw-r--r-- | player-android/lib/models/share.dart | 48 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/screens/my_shares_screen.dart | 374 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 24 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 20 | ||||
| -rw-r--r-- | player-android/test/screens/my_shares_screen_test.dart | 499 |
7 files changed, 973 insertions, 3 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index f7d81aa..32998aa 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -56,6 +56,10 @@ abstract final class AppRoutes { static String playerPathForType(String type, String mediaId) => type == 'audio' ? audioPlayerPath(mediaId) : videoPlayerPath(mediaId); + /// Route that lists all share links created by the authenticated user. + /// Opens [MySharesScreen] from Settings. + static const shares = '/shares'; + /// Returns the concrete path for the notes editor of a given [mediaId]. static String notesPath(String mediaId) => '/notes/$mediaId'; } diff --git a/player-android/lib/models/share.dart b/player-android/lib/models/share.dart index 210ac90..d3727f9 100644 --- a/player-android/lib/models/share.dart +++ b/player-android/lib/models/share.dart @@ -1,5 +1,10 @@ import 'json_helpers.dart'; +/// Represents a share link created by the authenticated user. +/// +/// The [fileName] and [mediaType] fields are only populated by the +/// `GET /api/v1/shares` (listMyShares) endpoint; per-media share endpoints +/// omit them. They are nullable so the model can be used for both shapes. class Share { final String token; final int mediaId; @@ -9,9 +14,46 @@ class Share { final int? maxUses; final int usedCount; - const Share({required this.token, required this.mediaId, required this.createdBy, this.createdAt, this.expiresAt, this.maxUses, required this.usedCount}); + /// Human-readable filename returned by [listMyShares]; null when not present. + final String? fileName; - factory Share.fromJson(Map<String, dynamic> json) => Share(token: json['token'] as String? ?? '', mediaId: json['media_id'] as int? ?? 0, createdBy: json['created_by'] as int? ?? 0, createdAt: dateTimeFromJson(json['created_at']), expiresAt: dateTimeFromJson(json['expires_at']), maxUses: json['max_uses'] as int?, usedCount: json['used_count'] as int? ?? 0); + /// Media type (e.g. "video", "audio") returned by [listMyShares]; null when + /// not present. + final String? mediaType; - Map<String, dynamic> toJson() => {'token': token, 'media_id': mediaId, 'created_by': createdBy, 'created_at': dateTimeToJson(createdAt), 'expires_at': dateTimeToJson(expiresAt), 'max_uses': maxUses, 'used_count': usedCount}; + const Share({ + required this.token, + required this.mediaId, + required this.createdBy, + this.createdAt, + this.expiresAt, + this.maxUses, + required this.usedCount, + this.fileName, + this.mediaType, + }); + + factory Share.fromJson(Map<String, dynamic> json) => Share( + token: json['token'] as String? ?? '', + mediaId: json['media_id'] as int? ?? 0, + createdBy: json['created_by'] as int? ?? 0, + createdAt: dateTimeFromJson(json['created_at']), + expiresAt: dateTimeFromJson(json['expires_at']), + maxUses: json['max_uses'] as int?, + usedCount: json['used_count'] as int? ?? 0, + fileName: json['file_name'] as String?, + mediaType: json['media_type'] as String?, + ); + + Map<String, dynamic> toJson() => { + 'token': token, + 'media_id': mediaId, + 'created_by': createdBy, + 'created_at': dateTimeToJson(createdAt), + 'expires_at': dateTimeToJson(expiresAt), + 'max_uses': maxUses, + 'used_count': usedCount, + if (fileName != null) 'file_name': fileName, + if (mediaType != null) 'media_type': mediaType, + }; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index e9a441e..03f14bc 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -16,6 +16,7 @@ import 'screens/media_grid_screen.dart'; import 'screens/podcast_list_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; +import 'screens/my_shares_screen.dart'; import 'screens/notes_editor_screen.dart'; import 'screens/video_player_screen.dart'; @@ -180,6 +181,12 @@ final routerProvider = Provider<GoRouter>((ref) { return NotesEditorScreen(mediaId: mediaId); }, ), + GoRoute( + // My Shares — lists all share links created by the authenticated user. + // Reachable from the Settings screen. + path: AppRoutes.shares, + builder: (context, state) => const MySharesScreen(), + ), ], ); }); 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), + ), ], ), ), diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index bb42f9b..6ce5f33 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -218,3 +218,23 @@ String notesErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.listMyShares] or +/// [PlayerApiClient.revokeShare] to a human-readable UI string. +/// +/// Adds a 404-specific message (share no longer exists) and a 403 message +/// (permission denied) so MySharesScreen can surface actionable guidance. +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String sharesErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Share not found. It may have already been revoked.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to manage this share.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/my_shares_screen_test.dart b/player-android/test/screens/my_shares_screen_test.dart new file mode 100644 index 0000000..111a455 --- /dev/null +++ b/player-android/test/screens/my_shares_screen_test.dart @@ -0,0 +1,499 @@ +// Widget tests for MySharesScreen (my_shares_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while listMyShares is in flight. +// 2. Renders a list of shares after a successful load. +// 3. Copy-link writes the share URL to the clipboard and shows a SnackBar. +// 4. Revoke removes the share from the list optimistically. +// 5. Revoke reverts the optimistic removal on API error. +// 6. Empty state is shown when listMyShares returns []. +// 7. Error state is shown when listMyShares throws. +// 8. Retry button re-calls listMyShares. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/my_shares_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/screens/my_shares_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain. +class _FakeTokenStorage implements TokenStorage { + const _FakeTokenStorage(); + + @override + Future<String?> readToken() async => 'test-token'; + + @override + Future<void> writeToken(String token) async {} + + @override + Future<void> deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [MySharesScreen] tests. +/// +/// [listMyShares] and [revokeShare] are the primary subjects. [shareUrl] is +/// also overridden so copy-link tests can assert the produced URL without +/// depending on the Dio base URL. All other methods remain [UnimplementedError]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + // ---- listMyShares ---- + + /// When non-null, [listMyShares] returns this list. + List<Share>? sharesResult; + + /// When non-null, [listMyShares] throws this instead of returning. + Object? sharesError; + + /// Number of times [listMyShares] has been called. + int listMySharesCallCount = 0; + + @override + Future<List<Share>> listMyShares() async { + listMySharesCallCount++; + if (sharesError != null) throw sharesError!; + return sharesResult!; + } + + // ---- revokeShare ---- + + /// When non-null, [revokeShare] throws this instead of returning normally. + Object? revokeError; + + /// The token passed to the last [revokeShare] call. + String? revokedToken; + + @override + Future<void> revokeShare(String token) async { + revokedToken = token; + if (revokeError != null) throw revokeError!; + } + + // ---- shareUrl ---- + + @override + String shareUrl(String token) => 'http://test.local/s/$token'; +} + +/// Controllable [PlayerApiClient] stub that delays [listMyShares] until +/// [complete] is called — used to inspect the mid-flight loading state. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer<List<Share>>(); + + /// Resolves the pending [listMyShares] with [shares]. + void complete(List<Share> shares) => _completer.complete(shares); + + @override + Future<List<Share>> listMyShares() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A share with all optional fields set. +final _kShareA = Share( + token: 'tok_aaa', + mediaId: 1, + createdBy: 1, + usedCount: 2, + maxUses: 10, + expiresAt: DateTime(2026, 12, 31), + fileName: 'movie.mp4', + mediaType: 'video', +); + +/// A share without an expiry or max-uses limit. +const _kShareB = Share( + token: 'tok_bbb', + mediaId: 2, + createdBy: 1, + usedCount: 0, + fileName: 'podcast.mp3', + mediaType: 'audio', +); + +// --------------------------------------------------------------------------- +// Helper: pump MySharesScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [MySharesScreen] inside a [ProviderScope] that overrides +/// [apiClientProvider] and [tokenStorageProvider] with fakes. +Future<void> _pumpMySharesScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: MySharesScreen(), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listMyShares is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpMySharesScreen(tester, fakeClient); + + // Pump a single frame: initState → addPostFrameCallback fires, but the + // Future has not resolved yet. + await tester.pump(); + + expect(find.byKey(const Key('shares_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve to avoid "async work pending" warnings. + fakeClient.complete([_kShareA]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders share list + // -------------------------------------------------------------------------- + + group('renders share list', () { + testWidgets('shows a tile for each share returned by listMyShares', + (tester) async { + final fakeClient = _FakeApiClient() + ..sharesResult = [_kShareA, _kShareB]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Both filenames must be visible. + expect(find.text('movie.mp4'), findsOneWidget); + expect(find.text('podcast.mp3'), findsOneWidget); + + // Tile widgets keyed by token. + expect(find.byKey(const Key('share_tile_tok_aaa')), findsOneWidget); + expect(find.byKey(const Key('share_tile_tok_bbb')), findsOneWidget); + }); + + testWidgets('renders list widget after a successful load', (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareA]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('shares_list')), findsOneWidget); + }); + + testWidgets('shows formatted expiry date when expiresAt is set', + (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareA]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // _kShareA.expiresAt == DateTime(2026, 12, 31). + expect(find.textContaining('2026-12-31'), findsOneWidget); + }); + + testWidgets('shows "No expiry" when expiresAt is null', (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareB]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.textContaining('No expiry'), findsOneWidget); + }); + + testWidgets('shows used count and max uses when maxUses is set', + (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareA]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // _kShareA.usedCount=2, maxUses=10 → "2/10 uses". + expect(find.textContaining('2/10 uses'), findsOneWidget); + }); + + testWidgets('shows used count without denominator when maxUses is null', + (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareB]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // _kShareB.usedCount=0, maxUses=null → "0 uses". + expect(find.textContaining('0 uses'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Copy-link action + // -------------------------------------------------------------------------- + + group('copy-link action', () { + testWidgets('copy-link writes share URL to clipboard and shows SnackBar', + (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = [_kShareA]; + + // Intercept clipboard calls. + final List<MethodCall> clipboardCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + clipboardCalls.add(call); + return null; + }, + ); + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap the copy-link button for _kShareA. + await tester.tap(find.byKey(const Key('share_copy_tok_aaa'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Clipboard.setData was called with the correct URL. + final setDataCall = clipboardCalls.firstWhere( + (c) => c.method == 'Clipboard.setData', + orElse: () => throw TestFailure('Clipboard.setData was not called'), + ); + final text = (setDataCall.arguments as Map)['text'] as String?; + expect(text, equals('http://test.local/s/tok_aaa')); + + // A SnackBar confirming the copy is visible. + expect(find.byKey(const Key('shares_copy_snackbar')), findsOneWidget); + expect( + find.textContaining('http://test.local/s/tok_aaa'), + findsOneWidget, + ); + + // Restore the default handler. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Revoke action + // -------------------------------------------------------------------------- + + group('revoke action', () { + testWidgets('revoke removes the share from the list optimistically', + (tester) async { + final fakeClient = _FakeApiClient() + ..sharesResult = [_kShareA, _kShareB]; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Both tiles visible before revoke. + expect(find.byKey(const Key('share_tile_tok_aaa')), findsOneWidget); + expect(find.byKey(const Key('share_tile_tok_bbb')), findsOneWidget); + + // Tap revoke for _kShareA. + await tester.tap(find.byKey(const Key('share_revoke_tok_aaa'))); + await tester.pumpAndSettle(); + + // _kShareA is gone; _kShareB remains. + expect(find.byKey(const Key('share_tile_tok_aaa')), findsNothing); + expect(find.byKey(const Key('share_tile_tok_bbb')), findsOneWidget); + + // Confirmation SnackBar is shown. + expect(find.byKey(const Key('shares_revoke_snackbar')), findsOneWidget); + + // revokeShare was called with the correct token. + expect(fakeClient.revokedToken, equals('tok_aaa')); + }); + + testWidgets('revoke reverts optimistic removal on API error', (tester) async { + final fakeClient = _FakeApiClient() + ..sharesResult = [_kShareA, _kShareB] + ..revokeError = DioException( + requestOptions: RequestOptions(path: '/api/v1/shares/tok_aaa'), + type: DioExceptionType.connectionError, + ); + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap revoke for _kShareA. + await tester.tap(find.byKey(const Key('share_revoke_tok_aaa'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // _kShareA is re-inserted after the error. + expect(find.byKey(const Key('share_tile_tok_aaa')), findsOneWidget); + + // Error SnackBar is shown. + expect( + find.byKey(const Key('shares_revoke_error_snackbar')), + findsOneWidget, + ); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when listMyShares returns []', + (tester) async { + final fakeClient = _FakeApiClient()..sharesResult = []; + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('shares_empty')), findsOneWidget); + expect(find.byKey(const Key('shares_list')), findsNothing); + expect(find.byKey(const Key('shares_loading')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listMyShares throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..sharesError = DioException( + requestOptions: RequestOptions(path: '/api/v1/shares'), + type: DioExceptionType.connectionError, + ); + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('shares_error')), findsOneWidget); + expect(find.byKey(const Key('shares_list')), findsNothing); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('shows retry button on error and retry calls listMyShares again', + (tester) async { + final fakeClient = _FakeApiClient() + ..sharesError = DioException( + requestOptions: RequestOptions(path: '/api/v1/shares'), + type: DioExceptionType.connectionError, + ); + + await _pumpMySharesScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('shares_ |
