summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/api/player_api_client.dart11
-rw-r--r--player-android/lib/router.dart6
-rw-r--r--player-android/lib/screens/home_screen.dart7
-rw-r--r--player-android/lib/screens/media_grid_screen.dart458
-rw-r--r--player-android/lib/utils/error_mappers.dart14
-rw-r--r--player-android/test/screens/media_grid_screen_test.dart445
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()
+ ..mediaError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/media'),
+ type: DioExceptionType.connectionError,
+ );
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Retry button is present.
+ expect(find.byKey(const Key('media_retry')), findsOneWidget);
+
+ // Fix the error before tapping retry so the second call succeeds.
+ fakeClient
+ ..mediaError = null
+ ..mediaResult = [_kVideo];
+
+ await tester.tap(find.byKey(const Key('media_retry')));
+ await tester.pumpAndSettle();
+
+ // After a successful retry the grid is shown.
+ expect(find.byKey(const Key('media_grid')), findsOneWidget);
+ // listMedia was called twice: once on init, once on retry.
+ expect(fakeClient.listMediaCallCount, equals(2));
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Pull-to-refresh
+ // --------------------------------------------------------------------------
+
+ group('pull-to-refresh', () {
+ testWidgets('pull-to-refresh calls listMedia a second time', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = [_kVideo];
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+