diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 08:16:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 08:16:59 +0300 |
| commit | 63fc4369917ab548c5cdc4dddccf0ad4ded11a2f (patch) | |
| tree | 7c7641c29a543cfebc4260cc590fe8bd5938ba43 | |
| parent | 6365d47f8257efa830d390c092ee9586aded9040 (diff) | |
Implement MediaGridScreen with grid, loading/empty/error states, and widget tests (ua)
- Replace placeholder MediaGridScreen with a full implementation: loads
media via listMedia(setId:), renders a Material 3 2-column grid of
thumbnail cards with filename, type icon, and duration overlay.
- Add thumbnailUrl(int mediaId) to PlayerApiClient so screen files
construct thumbnail URLs without importing Dio (DIP).
- Add mediaErrorMessage() top-level helper to error_mappers.dart
(Open-Closed Principle; matches setsErrorMessage pattern).
- Update router.dart to forward the set name as a route extra so
MediaGridScreen shows the name in the app bar without extra API calls.
- Update home_screen.dart _SetCard tap to pass set name as route extra.
- Add 10 widget tests covering: loading, grid render, tap navigation
to /media/:id, empty state, error state + retry, and pull-to-refresh.
- All 109 tests pass; flutter analyze reports no issues.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 11 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 6 | ||||
| -rw-r--r-- | player-android/lib/screens/home_screen.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/screens/media_grid_screen.dart | 458 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 14 | ||||
| -rw-r--r-- | player-android/test/screens/media_grid_screen_test.dart | 445 |
6 files changed, 922 insertions, 19 deletions
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 13b5ead..9e67d9e 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -133,6 +133,17 @@ class PlayerApiClient { Future<Uint8List> getThumbnail(int mediaId) => throw UnimplementedError(); + /// Returns the URL for a media item's thumbnail image. + /// + /// Constructing the URL here (rather than in the screen layer) keeps the API + /// path constant `/api/v1/media/{id}/thumbnail` in one place and avoids + /// exposing [Dio] or its [BaseOptions] to the UI layer (Dependency Inversion). + /// + /// The base URL is derived from the underlying Dio instance so it is always + /// consistent with the rest of the API calls. + String thumbnailUrl(int mediaId) => + '${rawDio.options.baseUrl}/api/v1/media/$mediaId/thumbnail'; + Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError(); Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError(); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 4f1934c..ee101a5 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -106,7 +106,11 @@ final routerProvider = Provider<GoRouter>((ref) { // The ':setId' path parameter is guaranteed by the route pattern. final raw = state.pathParameters['setId']!; final setId = int.tryParse(raw) ?? 0; - return MediaGridScreen(setId: setId); + // The set name is optionally passed as a route extra (String) by the + // calling screen (e.g. SetsListScreen) so the app bar can show it + // immediately without an extra API call. + final setName = state.extra is String ? state.extra as String : null; + return MediaGridScreen(setId: setId, setName: setName); }, ), GoRoute( diff --git a/player-android/lib/screens/home_screen.dart b/player-android/lib/screens/home_screen.dart index 9988496..b496538 100644 --- a/player-android/lib/screens/home_screen.dart +++ b/player-android/lib/screens/home_screen.dart @@ -192,7 +192,12 @@ class _SetCard extends StatelessWidget { key: Key('set_card_${mediaSet.id}'), clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => context.go(AppRoutes.mediaGridPath(mediaSet.id)), + // Pass the set name as a route extra so MediaGridScreen can show it in + // the app bar immediately, without making a second API call. + onTap: () => context.go( + AppRoutes.mediaGridPath(mediaSet.id), + extra: mediaSet.name, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/player-android/lib/screens/media_grid_screen.dart b/player-android/lib/screens/media_grid_screen.dart index 1a24620..6905243 100644 --- a/player-android/lib/screens/media_grid_screen.dart +++ b/player-android/lib/screens/media_grid_screen.dart @@ -1,40 +1,464 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; -/// Placeholder screen that will display the media items inside a [MediaSet]. +import '../app_routes.dart'; +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +/// Displays the media items inside a single [MediaSet] as a scrollable grid. /// -/// Navigation target reached when the user taps a set card on [SetsListScreen]. -/// The [setId] identifies which set to show; the actual media-listing logic -/// will be implemented in a future task. +/// Each card shows the item's thumbnail, title (file name), media-type icon +/// (video / audio / image), and formatted duration. Tapping a card navigates +/// to [MediaDetailScreen] via `/media/:id`. /// /// Design notes: -/// - [ConsumerWidget] is used so the screen can later watch Riverpod -/// providers for media data without changing the class hierarchy. -/// - [setId] is passed as a constructor parameter (not via global state) so -/// the screen is independently testable and reusable for any set. -class MediaGridScreen extends ConsumerWidget { +/// - [ConsumerStatefulWidget] allows local loading/error state, [mounted] +/// guards on async continuations, and pull-to-refresh without lifting +/// state into a global Riverpod notifier. +/// - [setId] is a constructor parameter (not route global state) so the +/// screen is independently testable and reusable for any set. +/// - [setName] is an optional display label passed as a route extra; the +/// app bar falls back to "Set $setId" when it is absent. +/// - Error handling is fully delegated to top-level helpers in +/// `error_mappers.dart` — no `dio` import in this file (DIP). +class MediaGridScreen extends ConsumerStatefulWidget { /// The numeric identifier of the set whose media items will be displayed. final int setId; - const MediaGridScreen({super.key, required this.setId}); + /// Optional human-readable name of the set shown in the app bar. + /// + /// Pass this as a route extra from the calling screen so the app bar shows + /// the set name immediately without a separate API call. + final String? setName; + + const MediaGridScreen({super.key, required this.setId, this.setName}); + + @override + ConsumerState<MediaGridScreen> createState() => _MediaGridScreenState(); +} + +class _MediaGridScreenState extends ConsumerState<MediaGridScreen> { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List<Media>? _media; + + // 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 the first load until after the first frame so [ref] is fully bound + // and any provider overrides in the test environment are applied. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches media items for [widget.setId] and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Errors are mapped by the + /// top-level [mediaErrorMessage] helper so the widget stays free of Dio. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(apiClientProvider); + final items = await client.listMedia(setId: widget.setId); + if (!mounted) return; + setState(() { + _media = items; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = mediaErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text('Set $setId'), + appBar: _buildAppBar(), + body: _buildBody(context), + ); + } + + /// Builds the app bar, showing [widget.setName] when available. + AppBar _buildAppBar() { + return AppBar( + title: Text(widget.setName ?? 'Set ${widget.setId}'), + ); + } + + /// Delegates to the appropriate state widget: + /// - Loading spinner (first load, before any data arrives). + /// - Error view with a retry button. + /// - Empty-state message when [listMedia] returns an empty list. + /// - Grid of media cards once data is available. + Widget _buildBody(BuildContext context) { + // Show a full-screen spinner only on the very first load (no data yet). + if (_isLoading && _media == null) { + return const Center( + key: Key('media_loading'), + child: CircularProgressIndicator(), + ); + } + + // Show an error view with a retry button if the load failed. + if (_error != null) { + return _ErrorView(message: _error!, onRetry: _load); + } + + // [RefreshIndicator] wraps the scrollable content so pull-to-refresh + // triggers [_load] on both the grid and the empty-state view. + return RefreshIndicator( + onRefresh: _load, + child: _media == null || _media!.isEmpty + ? const _EmptyView() + : _MediaGrid( + media: _media!, + thumbnailUrlBuilder: _thumbnailUrl, + ), + ); + } + + /// Delegates thumbnail URL construction to [PlayerApiClient] so this screen + /// stays free of Dio / URL-building logic (Single Responsibility). + String _thumbnailUrl(int mediaId) { + final client = ref.read(apiClientProvider); + return client.thumbnailUrl(mediaId); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable grid of [Media] cards. +/// +/// Extracted from [_MediaGridScreenState] so the state class stays concise and +/// the grid layout is independently testable. +class _MediaGrid extends StatelessWidget { + const _MediaGrid({ + required this.media, + required this.thumbnailUrlBuilder, + }); + + final List<Media> media; + + /// Callback that returns the full thumbnail URL for a given media ID. + /// + /// Injected rather than computed inline so the widget has no knowledge of + /// base-URL or API path structure (Dependency Inversion). + final String Function(int mediaId) thumbnailUrlBuilder; + + @override + Widget build(BuildContext context) { + return GridView.builder( + key: const Key('media_grid'), + padding: const EdgeInsets.all(12), + // Two columns on phones; adaptive count could be added for tablets later. + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + // Slightly taller than square to accommodate the info overlay. + childAspectRatio: 0.85, + ), + itemCount: media.length, + itemBuilder: (context, index) => _MediaCard( + item: media[index], + thumbnailUrl: thumbnailUrlBuilder(media[index].id), ), - body: Center( + ); + } +} + +/// Material 3 card for a single [Media] item. +/// +/// Shows: +/// - Thumbnail image with placeholder and error fallback. +/// - Semi-transparent overlay at the bottom with title, type icon, and +/// duration. +/// +/// Tapping navigates to [AppRoutes.mediaDetailPath] for the item. +class _MediaCard extends StatelessWidget { + const _MediaCard({required this.item, required this.thumbnailUrl}); + + final Media item; + final String thumbnailUrl; + + @override + Widget build(BuildContext context) { + return Card( + key: Key('media_card_${item.id}'), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go(AppRoutes.mediaDetailPath(item.id)), + child: Stack( + fit: StackFit.expand, + children: [ + // Thumbnail fills the full card area. + _ThumbnailImage(thumbnailUrl: thumbnailUrl), + // Info overlay anchored to the bottom of the card. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: _InfoOverlay(item: item), + ), + ], + ), + ), + ); + } +} + +/// Thumbnail image for a media card, loaded via [CachedNetworkImage]. +/// +/// Provides a grey placeholder while loading or when [thumbnailUrl] is empty, +/// and a broken-image icon on network error. Checking for empty URL before +/// attempting a network request mirrors the pattern used in [_CoverImage] +/// (home_screen.dart) and avoids unnecessary HTTP traffic when no thumbnail +/// is available. +class _ThumbnailImage extends StatelessWidget { + const _ThumbnailImage({required this.thumbnailUrl}); + + final String thumbnailUrl; + + @override + Widget build(BuildContext context) { + // When the URL is empty, skip the network request and show the placeholder + // immediately — consistent with the set-cover image pattern. + if (thumbnailUrl.isEmpty) return _placeholder(context); + + return CachedNetworkImage( + imageUrl: thumbnailUrl, + fit: BoxFit.cover, + placeholder: (_, __) => _loading(), + errorWidget: (_, __, ___) => _error(context), + ); + } + + static Widget _loading() => + const Center(child: CircularProgressIndicator()); + + static Widget _placeholder(BuildContext context) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.image_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + + static Widget _error(BuildContext context) => ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.broken_image_outlined, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); +} + +/// Semi-transparent overlay at the bottom of a media card. +/// +/// Displays: +/// - Type icon (video camera / headphones / image). +/// - Title truncated to one line. +/// - Formatted duration. +/// +/// The gradient background ensures text readability over any thumbnail. +class _InfoOverlay extends StatelessWidget { + const _InfoOverlay({required this.item}); + + final Media item; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withAlpha(200), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Type icon + truncated title on the same row. + Row( + children: [ + Icon( + _typeIcon(item.type), + size: 14, + color: Colors.white70, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + item.fileName, + key: Key('media_title_${item.id}'), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 2), + // Duration formatted as mm:ss or hh:mm:ss. + Text( + _formatDuration(item.duration), + key: Key('media_duration_${item.id}'), + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ), + ); + } + + /// Returns an appropriate icon for the given media [type]. + /// + /// Falls back to [Icons.insert_drive_file_outlined] for unknown types. + static IconData _typeIcon(String type) { + switch (type) { + case 'video': + return Icons.videocam_outlined; + case 'audio': + return Icons.headphones_outlined; + case 'image': + return Icons.image_outlined; + default: + return Icons.insert_drive_file_outlined; + } + } + + /// Formats [seconds] as `h:mm:ss` or `m:ss`, omitting leading zeros. + /// + /// Uses integer arithmetic only — no Duration formatting dependency — to + /// keep this helper lightweight and independently testable. + static String _formatDuration(double seconds) { + final total = seconds.truncate(); + final h = total ~/ 3600; + final m = (total % 3600) ~/ 60; + final s = total % 60; + if (h > 0) { + return '$h:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + } + return '$m:${s.toString().padLeft(2, '0')}'; + } +} + +/// Full-screen empty-state view, shown when [listMedia] returns an empty list. +/// +/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the +/// [RefreshIndicator] parent can still trigger a pull-to-refresh gesture. +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.video_library_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No media found', + key: const Key('media_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Pull down to refresh.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ); + } +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [listMedia] throws (network error, server error, etc.). +/// The [message] comes from [mediaErrorMessage], 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: [ - const Icon(Icons.construction_outlined, size: 56), + Icon( + Icons.error_outline, + size: 56, + color: Theme.of(context).colorScheme.error, + ), const SizedBox(height: 16), Text( - 'TODO: media grid for set $setId', - key: const Key('media_grid_todo'), + message, + key: const Key('media_error'), + textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('media_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), ], ), ), diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 5a9996a..7353928 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -77,3 +77,17 @@ String setsErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.listMedia] to a UI string. +/// +/// Identical delegation strategy to [setsErrorMessage]: DioExceptions are +/// mapped by [dioConnectionErrorMessage]; all other exceptions fall back to a +/// generic message. Having a separate function preserves the option to add +/// media-specific status-code overrides (e.g. 403 permission errors) later +/// without altering the sets helper (Open-Closed Principle). +String mediaErrorMessage(Object error) { + if (error is DioException) { + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/media_grid_screen_test.dart b/player-android/test/screens/media_grid_screen_test.dart new file mode 100644 index 0000000..986b901 --- /dev/null +++ b/player-android/test/screens/media_grid_screen_test.dart @@ -0,0 +1,445 @@ +// Widget tests for MediaGridScreen (media_grid_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while listMedia is in flight. +// 2. Renders a grid of media cards after a successful load. +// 3. Each card shows the media title and duration. +// 4. Tapping a card navigates to the media-detail route. +// 5. Shows an empty-state widget when listMedia returns []. +// 6. Shows an error view when listMedia throws a DioException. +// 7. Pull-to-refresh calls listMedia again. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/media_grid_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.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/media_grid_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed test token. +/// +/// Avoids the platform-specific OS keychain in widget 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 [MediaGridScreen] tests. +/// +/// Only [listMedia] and [thumbnailUrl] are implemented; all other methods +/// remain [UnimplementedError] — the screen calls only these two. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [listMedia] returns this list. + List<Media>? mediaResult; + + /// When non-null, [listMedia] throws this instead of returning. + Object? mediaError; + + /// Records every call to [listMedia] — useful for refresh tests. + int listMediaCallCount = 0; + + @override + Future<List<Media>> listMedia({ + String? search, + int? setId, + List<int>? setIds, + String? type, + bool? favorites, + List<String>? tags, + double? minDuration, + double? maxDuration, + int? fileSizeMin, + int? fileSizeMax, + String? sort, + int? limit, + int? offset, + String? folder, + String? parent, + }) async { + listMediaCallCount++; + if (mediaError != null) throw mediaError!; + return mediaResult!; + } + + /// Returns an empty string so [_ThumbnailImage] shows the static placeholder + /// instead of making a network request — keeps widget tests hermetic. + @override + String thumbnailUrl(int mediaId) => ''; +} + +/// [PlayerApiClient] stub that delays [listMedia] until [complete] is called. +/// +/// Used to inspect mid-flight loading state before the response arrives. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer<List<Media>>(); + + /// Resolves the pending [listMedia] call with [items]. + void complete(List<Media> items) => _completer.complete(items); + + @override + Future<List<Media>> listMedia({ + String? search, + int? setId, + List<int>? setIds, + String? type, + bool? favorites, + List<String>? tags, + double? minDuration, + double? maxDuration, + int? fileSizeMin, + int? fileSizeMax, + String? sort, + int? limit, + int? offset, + String? folder, + String? parent, + }) => + _completer.future; + + @override + String thumbnailUrl(int mediaId) => ''; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A sample video media item used across tests. +const _kVideo = Media( + id: 1, + setId: 10, + relPath: 'action/movie.mp4', + fileName: 'movie.mp4', + absPath: '/media/movies/action/movie.mp4', + type: 'video', + duration: 7320.0, // 2h 2m + codec: 'h264/aac', + resolution: '1920x1080', + bitrate: 4500, + fileSizeBytes: 1073741824, + width: 1920, + height: 1080, + thumbnailPath: '/media/movies/.thumbs/movie.jpg', + playCount: 3, +); + +/// A sample audio media item used across tests. +const _kAudio = Media( + id: 2, + setId: 10, + relPath: 'music/song.mp3', + fileName: 'song.mp3', + absPath: '/media/music/song.mp3', + type: 'audio', + duration: 210.0, // 3m 30s + codec: 'mp3', + resolution: '', + bitrate: 320, + fileSizeBytes: 8388608, + width: 0, + height: 0, + thumbnailPath: '', + playCount: 12, +); + +// --------------------------------------------------------------------------- +// Helper: pump MediaGridScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Destination route shown after navigating away from [MediaGridScreen]. +/// +/// Used in navigation tests: when a media card is tapped, [MediaGridScreen] +/// calls `context.go('/media/:id')` which this route catches. +const _kDestinationKey = Key('nav_destination'); + +/// Builds a [GoRouter] with [MediaGridScreen] at `/sets/:setId` and a stub +/// at `/media/:id` so navigation tests can verify the tap lands correctly. +GoRouter _buildRouter(PlayerApiClient fakeClient) { + return GoRouter( + initialLocation: '/sets/10', + routes: [ + GoRoute( + path: '/sets/:setId', + builder: (context, state) { + final setId = int.tryParse(state.pathParameters['setId']!) ?? 0; + return MediaGridScreen(setId: setId, setName: 'Movies'); + }, + ), + GoRoute( + path: '/media/:id', + builder: (context, state) => Scaffold( + body: Text( + 'Media ${state.pathParameters['id']}', + key: _kDestinationKey, + ), + ), + ), + ], + ); +} + +/// Pumps [MediaGridScreen] (set 10, name "Movies") inside a [ProviderScope] +/// that overrides [apiClientProvider] and [tokenStorageProvider] with fakes. +/// +/// Uses a [GoRouter] so `context.go('/media/:id')` works without an +/// "unsupported ancestor" error. The `/media/:id` stub route lets +/// navigation tests verify that the correct destination was reached. +Future<void> _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + final router = _buildRouter(fakeClient); + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: MaterialApp.router( + routerConfig: router, + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listMedia is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpScreen(tester, fakeClient); + + // Pump one frame: initState fires, addPostFrameCallback enqueues the + // load, and the Future has not resolved yet. + await tester.pump(); + + // The loading key should be visible before data arrives. + expect(find.byKey(const Key('media_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve the fake to prevent "pending async work" warnings. + fakeClient.complete([_kVideo]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders grid + // -------------------------------------------------------------------------- + + group('renders grid', () { + testWidgets('shows a card for each item returned by listMedia', + (tester) async { + final fakeClient = _FakeApiClient() + ..mediaResult = [_kVideo, _kAudio]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Both file names must be visible. + expect(find.text('movie.mp4'), findsOneWidget); + expect(find.text('song.mp3'), findsOneWidget); + + // A card widget is rendered for each item. + expect(find.byKey(const Key('media_card_1')), findsOneWidget); + expect(find.byKey(const Key('media_card_2')), findsOneWidget); + }); + + testWidgets('renders the media grid widget after a successful load', + (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = [_kVideo]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The grid itself is visible. + expect(find.byKey(const Key('media_grid')), findsOneWidget); + }); + + testWidgets('shows title and duration for each media card', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = [_kVideo]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Title key is present. + expect(find.byKey(const Key('media_title_1')), findsOneWidget); + // Duration key is present and shows formatted value. + expect(find.byKey(const Key('media_duration_1')), findsOneWidget); + // 7320s = 2h 2m 0s → "2:02:00" + expect(find.text('2:02:00'), findsOneWidget); + }); + + testWidgets('shows set name in app bar when provided', (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = []; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.text('Movies'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Tap navigation + // -------------------------------------------------------------------------- + + group('tap navigates to media detail', () { + testWidgets('tapping a media card navigates to /media/:id', + (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = [_kVideo]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The media card must be visible before tapping. + expect(find.byKey(const Key('media_card_1')), findsOneWidget); + + // Tap the card; go_router handles `context.go('/media/1')`. + await tester.tap(find.byKey(const Key('media_card_1'))); + await tester.pumpAndSettle(); + + // The stub route at '/media/:id' is now on screen. + expect(find.byKey(_kDestinationKey), findsOneWidget); + expect(find.text('Media 1'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when listMedia returns []', + (tester) async { + final fakeClient = _FakeApiClient()..mediaResult = []; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The empty-state text is shown; grid and loading indicator are not. + expect(find.byKey(const Key('media_empty')), findsOneWidget); + expect(find.byKey(const Key('media_grid')), findsNothing); + expect(find.byKey(const Key('media_loading')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listMedia throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..mediaError = DioException( + requestOptions: RequestOptions(path: '/api/v1/media'), + type: DioExceptionType.connectionError, + ); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Error widget is visible; grid and loading indicator are not. + expect(find.byKey(const Key('media_error')), findsOneWidget); + expect(find.byKey(const Key('media_grid')), findsNothing); + + // The error message mentions the server/connection. + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets( + 'shows retry button on error and a successful retry shows the grid', + (tester) async { + final fakeClient = _FakeApiClient() |
