summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 23:09:56 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 23:09:56 +0300
commitb33983f51c96db1d1b9e4e18d1478a3a993beb03 (patch)
tree371cdf91e60f3e50ce6441f3322aba9c6e21522d
parent5ca68ad6430b968039003926aefa98f268f129b0 (diff)
Implement MySharesScreen with revoke/copy-link actions reachable from Settings (8b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/app_routes.dart4
-rw-r--r--player-android/lib/models/share.dart48
-rw-r--r--player-android/lib/router.dart7
-rw-r--r--player-android/lib/screens/my_shares_screen.dart374
-rw-r--r--player-android/lib/screens/settings_screen.dart24
-rw-r--r--player-android/lib/utils/error_mappers.dart20
-rw-r--r--player-android/test/screens/my_shares_screen_test.dart499
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 _pump