diff options
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 34 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/screens/create_share_dialog.dart | 342 | ||||
| -rw-r--r-- | player-android/lib/screens/media_detail_screen.dart | 77 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 20 | ||||
| -rw-r--r-- | player-android/test/screens/create_share_dialog_test.dart | 457 |
6 files changed, 936 insertions, 1 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index 533c951..aee73f8 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -332,6 +332,40 @@ class DioPlayerApiClient extends PlayerApiClient { } // --------------------------------------------------------------------------- + // Shares + // --------------------------------------------------------------------------- + + /// Creates a share link for [mediaId]. + /// + /// POST /api/v1/media/{id}/shares + /// + /// [expiresAt] and [maxUses] are sent as optional JSON fields so the server + /// can apply custom expiry and use-count limits in place of its built-in + /// defaults. Null values are omitted from the request body so the server + /// falls back to [SHARE_DEFAULT_EXPIRY_DAYS] for expiry and unlimited uses. + /// + /// Returns the newly created [Share] on success. + @override + Future<Share> createShare( + int mediaId, { + DateTime? expiresAt, + int? maxUses, + }) async { + // Build the optional request body; omit null fields so the server applies + // its own defaults rather than receiving explicit nulls. + final body = <String, dynamic>{ + if (expiresAt != null) 'expires_at': expiresAt.toUtc().toIso8601String(), + if (maxUses != null) 'max_uses': maxUses, + }; + + final response = await rawDio.post<Map<String, dynamic>>( + '$_kApiV1/media/$mediaId/shares', + data: body.isNotEmpty ? body : null, + ); + return Share.fromJson(response.data!); + } + + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 7af4c8d..49bb8e0 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -153,6 +153,13 @@ class PlayerApiClient { String streamUrl(int mediaId) => '${rawDio.options.baseUrl}/api/v1/media/$mediaId/stream'; + /// Returns the public share URL for a share [token]. + /// + /// Mirrors [thumbnailUrl] and [streamUrl]: the share path `/s/{token}` is + /// kept in one place so the UI layer never needs to access Dio internals or + /// hard-code URL segments (Dependency Inversion Principle). + String shareUrl(String token) => '${rawDio.options.baseUrl}/s/$token'; + Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError(); Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError(); diff --git a/player-android/lib/screens/create_share_dialog.dart b/player-android/lib/screens/create_share_dialog.dart new file mode 100644 index 0000000..30123f0 --- /dev/null +++ b/player-android/lib/screens/create_share_dialog.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../api/player_api_client.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// showCreateShareDialog — public entry point +// --------------------------------------------------------------------------- + +/// Opens the [_CreateShareDialog] as a modal dialog and returns the share URL +/// if the user successfully created a share, or `null` if they cancelled. +/// +/// Separating the route function from the widget (Single Responsibility) +/// means call sites never need to construct the dialog class directly; they +/// only call this function and react to the returned URL. +/// +/// [client] must be the authenticated [PlayerApiClient] — no Dio import is +/// needed at the call site (Dependency Inversion Principle). +Future<String?> showCreateShareDialog( + BuildContext context, { + required int mediaId, + required PlayerApiClient client, +}) { + return showDialog<String>( + context: context, + // Prevent accidental dismiss while a share request is in flight by using + // barrierDismissible: false only during submit (handled inside the dialog + // with the loading flag; here we allow tap-outside to cancel). + barrierDismissible: true, + builder: (_) => _CreateShareDialog(mediaId: mediaId, client: client), + ); +} + +// --------------------------------------------------------------------------- +// _CreateShareDialog +// --------------------------------------------------------------------------- + +/// Modal dialog that collects share settings (expiry date, optional max uses) +/// and calls [PlayerApiClient.createShare] on submit. +/// +/// Design notes: +/// - [StatefulWidget] (not [ConsumerStatefulWidget]) because the dialog +/// only needs the injected [client]; it does not read Riverpod providers +/// directly (Dependency Inversion: the caller owns the provider read). +/// - [mounted] guards protect every async continuation. +/// - No Dio import: error mapping is delegated to [createShareErrorMessage] +/// in `error_mappers.dart` (DIP/DRY). +/// - The widget is split into focused sub-builders so the [State] class +/// stays well under 50 lines. +class _CreateShareDialog extends StatefulWidget { + const _CreateShareDialog({ + required this.mediaId, + required this.client, + }); + + final int mediaId; + final PlayerApiClient client; + + @override + State<_CreateShareDialog> createState() => _CreateShareDialogState(); +} + +class _CreateShareDialogState extends State<_CreateShareDialog> { + // Default expiry is today + 7 days, matching the server's default. + late DateTime _expiresAt = DateTime.now().add(const Duration(days: 7)); + + // Controller for the optional max-uses text field. + final _maxUsesController = TextEditingController(); + + // True while the createShare API call is in flight; disables buttons. + bool _isSubmitting = false; + + // Non-null when the last submit attempt failed. + String? _error; + + @override + void dispose() { + _maxUsesController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + /// Opens the platform date picker for the expiry field. + /// + /// The picker starts at the current [_expiresAt] value and only allows + /// dates from today onwards to prevent creating already-expired links. + Future<void> _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _expiresAt, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 3650)), + ); + // Guard: dialog may have been closed while the date picker was open. + if (!mounted) return; + if (picked != null) { + setState(() => _expiresAt = picked); + } + } + + /// Parses max-uses input, calls createShare, and delegates success handling. + /// + /// Acts as an orchestrator: input validation → API call → [_handleSuccess] + /// or error display. Clipboard copy and SnackBar are kept in [_handleSuccess] + /// (Single Responsibility) so each method stays focused and independently + /// testable. On failure an inline error message is shown inside the dialog + /// so the user can correct input without reopening. + Future<void> _submit() async { + if (_isSubmitting) return; + + // Parse max uses — empty input means unlimited (null). + final maxUsesText = _maxUsesController.text.trim(); + final int? maxUses = maxUsesText.isEmpty ? null : int.tryParse(maxUsesText); + if (maxUsesText.isNotEmpty && maxUses == null) { + setState(() => _error = 'Max uses must be a whole number.'); + return; + } + + setState(() { + _isSubmitting = true; + _error = null; + }); + + try { + final share = await widget.client.createShare( + widget.mediaId, + expiresAt: _expiresAt, + maxUses: maxUses, + ); + + // Delegate URL construction to the client (Dependency Inversion Principle): + // the dialog never accesses Dio internals or hard-codes path segments. + final url = widget.client.shareUrl(share.token); + + if (!mounted) return; + await _handleSuccess(context, url); + } catch (e) { + if (!mounted) return; + setState(() { + _error = createShareErrorMessage(e); + _isSubmitting = false; + }); + } + } + + /// Copies [shareUrl] to the clipboard, closes the dialog, and shows a + /// SnackBar confirming the copy. + /// + /// Extracted from [_submit] so the clipboard/SnackBar responsibility lives in + /// one place (Single Responsibility). Navigator and ScaffoldMessenger are + /// captured before the first `await` so they are never accessed across an + /// async gap via BuildContext (avoids use_build_context_synchronously lint). + Future<void> _handleSuccess(BuildContext context, String shareUrl) async { + // Capture navigator and messenger before the async gap so that accessing + // them after the await is safe and lint-clean. + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + // Copy the URL to the clipboard before closing the dialog so the + // user gets the copy confirmation even if the SnackBar is missed. + await Clipboard.setData(ClipboardData(text: shareUrl)); + + // Re-check mounted after the clipboard await; the dialog may have been + // closed externally (e.g. back-button) while the clipboard write was pending. + if (!mounted) return; + + // Close the dialog and pass the URL back to the caller. + navigator.pop(shareUrl); + + // Show a success SnackBar via the outer Scaffold's messenger. + messenger.showSnackBar( + SnackBar( + content: Text('Share link copied: $shareUrl'), + duration: const Duration(seconds: 4), + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('create_share_dialog'), + title: const Text('Create Share Link'), + content: _buildContent(context), + actions: _buildActions(context), + ); + } + + /// Dialog body: expiry date row, max-uses field, and optional error message. + Widget _buildContent(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ExpiryRow(expiresAt: _expiresAt, onPickDate: _pickDate), + const SizedBox(height: 16), + _MaxUsesField(controller: _maxUsesController), + if (_error != null) ...[ + const SizedBox(height: 12), + _ErrorText(message: _error!), + ], + ], + ); + } + + /// Cancel and Submit action buttons. + /// + /// Both are disabled while [_isSubmitting] is true so a second tap cannot + /// race with the first in-flight request. + List<Widget> _buildActions(BuildContext context) { + return [ + TextButton( + key: const Key('create_share_cancel'), + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('create_share_submit'), + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Share'), + ), + ]; + } +} + +// --------------------------------------------------------------------------- +// _ExpiryRow +// --------------------------------------------------------------------------- + +/// Row displaying the current expiry date with a button to change it. +/// +/// Extracted as a stateless widget (Single Responsibility) so [_CreateShareDialogState] +/// stays concise and this row is independently testable. +class _ExpiryRow extends StatelessWidget { + const _ExpiryRow({required this.expiresAt, required this.onPickDate}); + + final DateTime expiresAt; + final VoidCallback onPickDate; + + @override + Widget build(BuildContext context) { + final formatted = + '${expiresAt.year}-${expiresAt.month.toString().padLeft(2, '0')}-${expiresAt.day.toString().padLeft(2, '0')}'; + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Expires on', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 2), + Text( + formatted, + key: const Key('create_share_expiry_date'), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + TextButton.icon( + key: const Key('create_share_pick_date'), + onPressed: onPickDate, + icon: const Icon(Icons.calendar_today, size: 18), + label: const Text('Change'), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// _MaxUsesField +// --------------------------------------------------------------------------- + +/// Optional numeric text field for max-uses limit. +/// +/// Empty input means unlimited uses (null sent to the server). The numeric +/// keyboard type prevents non-digit input on most platforms. +class _MaxUsesField extends StatelessWidget { + const _MaxUsesField({required this.controller}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return TextField( + key: const Key('create_share_max_uses'), + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Max uses (leave blank for unlimited)', + hintText: 'e.g. 10', + border: OutlineInputBorder(), + isDense: true, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _ErrorText +// --------------------------------------------------------------------------- + +/// Inline error message shown when the createShare API call fails. +/// +/// Uses the error colour from [ColorScheme] for semantic consistency with +/// other error states in the app. +class _ErrorText extends StatelessWidget { + const _ErrorText({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Text( + message, + key: const Key('create_share_error'), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ); + } +} diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart index d272076..a0fc46c 100644 --- a/player-android/lib/screens/media_detail_screen.dart +++ b/player-android/lib/screens/media_detail_screen.dart @@ -7,6 +7,7 @@ import '../app_routes.dart'; import '../models/models.dart'; import '../providers/api_client_provider.dart'; import '../utils/error_mappers.dart'; +import 'create_share_dialog.dart'; // --------------------------------------------------------------------------- // MediaDetailScreen @@ -151,6 +152,33 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { } // --------------------------------------------------------------------------- + // Share + // --------------------------------------------------------------------------- + + /// Opens [showCreateShareDialog] for the current media item. + /// + /// Delegates all share logic (date picker, max uses, clipboard copy) to + /// [CreateShareDialog] so this class remains focused on media display and + /// navigation (Single Responsibility). The injected [PlayerApiClient] is + /// passed directly so the dialog never needs its own provider read — keeping + /// the dialog provider-free and independently testable (Dependency Inversion). + Future<void> _share() async { + final media = _media; + if (media == null || !mounted) return; + + final client = ref.read(apiClientProvider); + // showCreateShareDialog is async; the mounted check after the await guards + // against the widget being disposed while the dialog is open. + await showCreateShareDialog( + context, + mediaId: media.id, + client: client, + ); + // No post-dialog state update needed: the dialog handles clipboard copy + // and the SnackBar internally. + } + + // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- @@ -195,10 +223,46 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { ); } - /// Builds the app bar; shows the media title when available. + /// Builds the app bar with title and a three-dot overflow menu. + /// + /// The overflow menu currently contains a single "Share" action that opens + /// [showCreateShareDialog]. Using a [PopupMenuButton] rather than a plain + /// [IconButton] keeps the pattern open for future menu items without layout + /// changes. The [onSelected] callback uses a [Map]-based dispatch so adding + /// a new action requires only a new enum value and one map entry — no + /// if/else chain to extend (Open-Closed Principle). The Share action is + /// disabled while media is still loading (null) to prevent calling the API + /// with a stale ID. AppBar _buildAppBar() { return AppBar( title: Text(_media?.fileName ?? 'Media ${widget.mediaId}'), + actions: [ + PopupMenuButton<_MenuAction>( + key: const Key('media_detail_overflow_menu'), + onSelected: (action) { + // Map-based dispatch: adding a new menu action requires only a new + // enum value, a handler method, and one entry here — no if/else + // chain to extend (Open-Closed Principle). + final handlers = <_MenuAction, VoidCallback>{ + _MenuAction.share: _share, + }; + handlers[action]?.call(); + }, + itemBuilder: (_) => [ + PopupMenuItem<_MenuAction>( + key: const Key('media_detail_share_menu_item'), + // Disable the item until media has loaded so the mediaId is valid. + enabled: _media != null, + value: _MenuAction.share, + child: const ListTile( + leading: Icon(Icons.share), + title: Text('Share'), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], ); } @@ -237,6 +301,17 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { } // --------------------------------------------------------------------------- +// _MenuAction +// --------------------------------------------------------------------------- + +/// Enum of available overflow-menu actions in [MediaDetailScreen]. +/// +/// Using a typed enum (rather than raw strings) makes [PopupMenuButton] type +/// safe and avoids stringly-typed comparisons in [onSelected] (type safety / +/// Open-Closed: add new actions here without touching the menu-builder switch). +enum _MenuAction { share } + +// --------------------------------------------------------------------------- // _MediaDetailContent // --------------------------------------------------------------------------- diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index e90b9c2..c1ca5e9 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -109,3 +109,23 @@ String mediaDetailErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.createShare] to a UI string. +/// +/// Adds a 404-specific message (media not found) and a 403 message +/// (permission denied) on top of the generic connection-error fallback, so +/// the share dialog can surface actionable guidance rather than a raw code. +/// Kept as a separate function (Open-Closed) so it can evolve independently +/// of the other mappers. +String createShareErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Media not found. It may have been deleted.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to share this item.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/create_share_dialog_test.dart b/player-android/test/screens/create_share_dialog_test.dart new file mode 100644 index 0000000..9709e37 --- /dev/null +++ b/player-android/test/screens/create_share_dialog_test.dart @@ -0,0 +1,457 @@ +// Widget tests for CreateShareDialog (create_share_dialog.dart). +// +// Tests cover: +// 1. Dialog renders the expiry date field and max-uses field. +// 2. Tapping "Change" opens the date picker. +// 3. Successful share call copies the URL to the clipboard and shows a SnackBar. +// 4. API error is displayed inline inside the dialog. +// 5. Non-numeric max-uses input shows a validation error without calling the API. +// 6. Overflow menu item opens the share dialog from MediaDetailScreen. +// +// No Dio import at test level: [_FakeApiClient] overrides only the relevant +// [PlayerApiClient] methods, keeping tests hermetic (DIP/SRP). +// +// Run with: flutter test test/screens/create_share_dialog_test.dart + +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:go_router/go_router.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/create_share_dialog.dart'; +import 'package:player_android/screens/media_detail_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that avoids the OS keychain in tests. +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 share-dialog tests. +/// +/// [createShare] is the primary subject; [getMedia], [toggleFavorite], and +/// [thumbnailUrl] are also overridden to let tests pump [MediaDetailScreen] +/// without hitting [UnimplementedError]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + /// When non-null [createShare] returns this share. + Share? shareResult; + + /// When non-null [createShare] throws this instead of returning. + Object? shareError; + + /// Captured arguments from the last [createShare] call. + int? capturedMediaId; + DateTime? capturedExpiresAt; + int? capturedMaxUses; + + /// How many times [createShare] was called. + int createShareCallCount = 0; + + @override + Future<Share> createShare( + int mediaId, { + DateTime? expiresAt, + int? maxUses, + }) async { + createShareCallCount++; + capturedMediaId = mediaId; + capturedExpiresAt = expiresAt; + capturedMaxUses = maxUses; + + if (shareError != null) throw shareError!; + return shareResult!; + } + + // --------------------------------------------------------------------------- + // Required stubs for MediaDetailScreen integration tests + // --------------------------------------------------------------------------- + + Media? mediaResult; + + @override + Future<Media> getMedia(int mediaId) async => mediaResult!; + + @override + Future<bool> toggleFavorite(int mediaId) async => false; + + @override + String thumbnailUrl(int mediaId) => ''; + + @override + String streamUrl(int mediaId) => 'http://test.local/api/v1/media/$mediaId/stream'; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A minimal [Share] returned by the fake API. +final _kShare = Share( + token: 'tok_abc123', + mediaId: 42, + createdBy: 1, + usedCount: 0, + expiresAt: DateTime.now().add(const Duration(days: 7)), +); + +/// Expected URL derived from [_kShare] and the fake base URL. +const _kShareUrl = 'http://test.local/s/tok_abc123'; + +/// A minimal video [Media] item used in integration tests. +const _kMedia = Media( + id: 42, + setId: 1, + relPath: 'video.mp4', + fileName: 'video.mp4', + absPath: '/media/video.mp4', + type: 'video', + duration: 60.0, + codec: 'h264', + resolution: '1920x1080', + bitrate: 4000, + fileSizeBytes: 1048576, + width: 1920, + height: 1080, + thumbnailPath: '', + playCount: 0, + favorite: false, + tags: [], +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Pumps [showCreateShareDialog] inside a bare [Scaffold] + [ProviderScope]. +/// +/// The dialog is opened immediately after pump so tests do not need to trigger +/// it through a button — they can inspect and interact with the dialog content +/// directly. +Future<_FakeApiClient> _pumpDialog( + WidgetTester tester, { + Share? shareResult, + Object? shareError, +}) async { + final fakeClient = _FakeApiClient() + ..shareResult = shareResult + ..shareError = shareError; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + // Open the dialog immediately after the frame is built. + WidgetsBinding.instance.addPostFrameCallback((_) { + showCreateShareDialog( + context, + mediaId: 42, + client: fakeClient, + ); + }); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + // First pump: renders the Scaffold. + // Second pump (settle): executes the addPostFrameCallback and renders dialog. + await tester.pumpAndSettle(); + + return fakeClient; +} + +/// Pumps [MediaDetailScreen] with the given [fakeClient] and waits for the +/// screen to finish loading. +Future<void> _pumpMediaDetailScreen( + WidgetTester tester, + _FakeApiClient fakeClient, +) async { + final router = GoRouter( + initialLocation: '/media/42', + routes: [ + GoRoute( + path: '/media/:id', + builder: (context, state) => + MediaDetailScreen(mediaId: state.pathParameters['id']!), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + + await tester.pumpAndSettle(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // --------------------------------------------------------------------------- + // Dialog rendering + // --------------------------------------------------------------------------- + + group('renders dialog fields', () { + testWidgets('shows expiry date and max-uses field', (tester) async { + await _pumpDialog(tester, shareResult: _kShare); + + expect(find.byKey(const Key('create_share_dialog')), findsOneWidget); + expect(find.byKey(const Key('create_share_expiry_date')), findsOneWidget); + expect(find.byKey(const Key('create_share_max_uses')), findsOneWidget); + expect(find.byKey(const Key('create_share_submit')), findsOneWidget); + expect(find.byKey(const Key('create_share_cancel')), findsOneWidget); + }); + + testWidgets('default expiry date is approximately today + 7 days', + (tester) async { + await _pumpDialog(tester, shareResult: _kShare); + + // The displayed date should be the formatted version of now + 7 days. + final expected = DateTime.now().add(const Duration(days: 7)); + final formatted = + '${expected.year}-${expected.month.toString().padLeft(2, '0')}-${expected.day.toString().padLeft(2, '0')}'; + + expect(find.text(formatted), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Successful share + // --------------------------------------------------------------------------- + + group('successful share', () { + testWidgets('copies share URL to clipboard and shows SnackBar', + (tester) async { + // Intercept clipboard calls so we can assert the written value. + final List<MethodCall> clipboardCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + clipboardCalls.add(call); + // Return null for setData; Flutter's test harness expects this. + return null; + }, + ); + + final fakeClient = await _pumpDialog(tester, shareResult: _kShare); + + // Tap the submit button. + await tester.tap(find.byKey(const Key('create_share_submit'))); + // Pump frames to process the async _submit chain, navigator pop, and + // SnackBar entry animation. We pump with a short explicit duration so + // we do not wait for the SnackBar's 4-second display timer (which would + // cause pumpAndSettle to loop forever). 300 ms is enough to clear the + // material dialog-close animation (~200 ms) without touching the timer. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // The dialog should have closed. + expect(find.byKey(const Key('create_share_dialog')), findsNothing); + + // createShare was called once. + expect(fakeClient.createShareCallCount, equals(1)); + + // Clipboard.setData was called with the expected share 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(_kShareUrl)); + + // A SnackBar with the URL should be visible. + expect(find.textContaining('Share link copied'), findsOneWidget); + expect(find.textContaining(_kShareUrl), findsOneWidget); + + // Restore the default handler. + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); + + testWidgets('passes expiresAt and null maxUses to createShare when field is blank', + (tester) async { + final fakeClient = await _pumpDialog(tester, shareResult: _kShare); + + await tester.tap(find.byKey(const Key('create_share_submit'))); + // Pump twice to process the async _submit result and apply the widget + // rebuild that follows it (dialog close, SnackBar entry). Avoid + // pumpAndSettle because the SnackBar timer would cause an infinite loop. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(fakeClient.capturedMaxUses, isNull); + expect(fakeClient.capturedExpiresAt, isNotNull); + }); + + testWidgets('passes parsed maxUses to createShare when field is filled', + (tester) async { + final fakeClient = await _pumpDialog(tester, shareResult: _kShare); + + await tester.enterText( + find.byKey(const Key('create_share_max_uses')), + '5', + ); + await tester.tap(find.byKey(const Key('create_share_submit'))); + // Pump twice to process the async _submit result and apply the widget + // rebuild that follows it (dialog close, SnackBar entry). Avoid + // pumpAndSettle because the SnackBar timer would cause an infinite loop. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(fakeClient.capturedMaxUses, equals(5)); + }); + }); + + // --------------------------------------------------------------------------- + // Error display + // --------------------------------------------------------------------------- + + group('error display', () { + testWidgets('shows inline error when API throws a network error', + (tester) async { + final networkError = DioException( + requestOptions: RequestOptions(path: '/api/v1/media/42/shares'), + type: DioExceptionType.connectionError, + ); + + await _pumpDialog(tester, shareError: networkError); + + await tester.tap(find.byKey(const Key('create_share_submit'))); + await tester.pumpAndSettle(); + + // Dialog stays open (error is inline). + expect(find.byKey(const Key('create_share_dialog')), findsOneWidget); + expect(find.byKey(const Key('create_share_error')), findsOneWidget); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('shows 404 "not found" message on DioException 404', + (tester) async { + final notFoundError = DioException( + requestOptions: RequestOptions(path: '/api/v1/media/42/shares'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/api/v1/media/42/shares'), + statusCode: 404, + ), + ); + + await _pumpDialog(tester, shareError: notFoundError); + + await tester.tap(find.byKey(const Key('create_share_submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('create_share_error')), findsOneWidget); + expect(find.textContaining('Media not found'), findsOneWidget); + }); + + testWidgets('shows validation error when max uses is non-numeric', + (tester) async { + final fakeClient = await _pumpDialog(tester, shareResult: _kShare); + + await tester.enterText( + find.byKey(const Key('create_share_max_uses')), + 'abc', + ); + await tester.tap(find.byKey(const Key('create_share_submit'))); + await tester.pumpAndSettle(); + + // Dialog stays open; API was NOT called. + expect(find.byKey(const Key('create_share_dialog')), findsOneWidget); + expect(fakeClient.createShareCallCount, equals(0)); + expect(find.byKey(const Key('create_share_error')), findsOneWidget); + expect(find.textContaining('whole number'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Cancel + // --------------------------------------------------------------------------- + + group('cancel', () { + testWidgets('tapping Cancel closes the dialog without calling the API', + (tester) async { + |
