summaryrefslogtreecommitdiff
path: root/player-android/lib
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 08:16:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 08:16:59 +0300
commit63fc4369917ab548c5cdc4dddccf0ad4ded11a2f (patch)
tree7c7641c29a543cfebc4260cc590fe8bd5938ba43 /player-android/lib
parent6365d47f8257efa830d390c092ee9586aded9040 (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>
Diffstat (limited to 'player-android/lib')
-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
5 files changed, 477 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.';
+}