summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 09:15:03 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 09:15:03 +0300
commit12c967f39c35c5ffaf5714e77aae1e9b0c018733 (patch)
tree044acccb4141bab533397a23ddac20bb7fb1b392
parentb302c60cdeb24ad7f5d3bb39fd6e234c79e1e4d8 (diff)
Implement CreateShareDialog with expiry/max-uses, shareUrl abstraction (7b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/api/dio_player_api_client.dart34
-rw-r--r--player-android/lib/api/player_api_client.dart7
-rw-r--r--player-android/lib/screens/create_share_dialog.dart342
-rw-r--r--player-android/lib/screens/media_detail_screen.dart77
-rw-r--r--player-android/lib/utils/error_mappers.dart20
-rw-r--r--player-android/test/screens/create_share_dialog_test.dart457
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