summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 08:38:29 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 08:38:29 +0300
commit1edf0e1add29f7f342d38b595a7a563a75414d0c (patch)
tree693137c689b8118960cad73231832fc235bc765a
parent09f0ee28b6a7b7fc588a8a4e387d94ffb6848af3 (diff)
Implement MediaDetailScreen with metadata, favorite toggle, player routing (ya)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/api/player_api_client.dart9
-rw-r--r--player-android/lib/screens/media_detail_screen.dart604
-rw-r--r--player-android/lib/utils/error_mappers.dart18
-rw-r--r--player-android/test/screens/media_detail_screen_test.dart516
4 files changed, 1139 insertions, 8 deletions
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart
index 9e67d9e..e9d99a0 100644
--- a/player-android/lib/api/player_api_client.dart
+++ b/player-android/lib/api/player_api_client.dart
@@ -144,6 +144,15 @@ class PlayerApiClient {
String thumbnailUrl(int mediaId) =>
'${rawDio.options.baseUrl}/api/v1/media/$mediaId/thumbnail';
+ /// Returns the URL used to stream a media item.
+ ///
+ /// Mirrors [thumbnailUrl]: the API path `/api/v1/media/{id}/stream` is kept
+ /// in one place and Dio internals are never leaked into the UI layer.
+ /// The base URL is derived from the underlying Dio instance so it stays
+ /// consistent with every other API call.
+ String streamUrl(int mediaId) =>
+ '${rawDio.options.baseUrl}/api/v1/media/$mediaId/stream';
+
Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError();
Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError();
diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart
index c705a98..d272076 100644
--- a/player-android/lib/screens/media_detail_screen.dart
+++ b/player-android/lib/screens/media_detail_screen.dart
@@ -1,20 +1,608 @@
+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';
-/// Media-detail screen — will display a single media item with player controls.
+import '../app_routes.dart';
+import '../models/models.dart';
+import '../providers/api_client_provider.dart';
+import '../utils/error_mappers.dart';
+
+// ---------------------------------------------------------------------------
+// MediaDetailScreen
+// ---------------------------------------------------------------------------
+
+/// Displays a single media item with its title, full metadata (codec,
+/// resolution, duration, file size), a thumbnail banner, a favourite toggle,
+/// tag chips, and a play button that routes to the correct player.
///
-/// Currently a lightweight placeholder; feature implementation will replace
-/// the body without touching the router or other screens.
-class MediaDetailScreen extends StatelessWidget {
+/// Design notes:
+/// - [ConsumerStatefulWidget] is used so we can hold local loading/error
+/// state, guard async continuations with [mounted], and call [setState]
+/// to trigger rebuilds after the favourite toggle.
+/// - [getMedia] is called from [initState] (via a post-frame callback so the
+/// Riverpod ref is fully bound) and on pull-to-refresh.
+/// - No `dio` import — error mapping is delegated to [mediaDetailErrorMessage]
+/// in `error_mappers.dart` (Dependency Inversion Principle).
+/// - The screen is split into multiple focused sub-widgets so the state
+/// class stays well under 50 lines.
+class MediaDetailScreen extends ConsumerStatefulWidget {
+ /// The string form of the media ID extracted from the '/media/:id' route.
+ final String mediaId;
+
const MediaDetailScreen({super.key, required this.mediaId});
- /// The string form of the media ID extracted from the route path parameter.
- final String mediaId;
+ @override
+ ConsumerState<MediaDetailScreen> createState() => _MediaDetailScreenState();
+}
+
+class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> {
+ // Nullable: null means the first load has not completed yet.
+ Media? _media;
+
+ // Non-null when the last load attempt failed.
+ String? _error;
+
+ // True while a getMedia call is in flight (shows the full-screen spinner).
+ bool _isLoading = false;
+
+ // True while a toggleFavorite call is in flight; prevents concurrent taps
+ // from queuing up multiple API calls that could result in a desync.
+ bool _isFavoriteLoading = 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 the media item from the server and updates local state.
+ ///
+ /// Called on first mount and on pull-to-refresh. Errors are mapped by the
+ /// top-level [mediaDetailErrorMessage] helper so no `dio` import is needed.
+ Future<void> _load() async {
+ if (!mounted) return;
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final id = int.tryParse(widget.mediaId) ?? 0;
+ final client = ref.read(apiClientProvider);
+ final media = await client.getMedia(id);
+ if (!mounted) return;
+ setState(() {
+ _media = media;
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = mediaDetailErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Favourite toggle
+ // ---------------------------------------------------------------------------
+
+ /// Calls [toggleFavorite] on the server and reflects the new state locally.
+ ///
+ /// The server returns the new favourite state; we apply it to the in-memory
+ /// [_media] copy so the UI updates immediately without a full reload.
+ /// If the call fails, a snack-bar is shown and the toggle is reverted
+ /// (the local state was not yet changed, so no explicit revert is needed).
+ ///
+ /// [_isFavoriteLoading] is set to true for the duration of the call to block
+ /// concurrent taps that could otherwise race and desync the UI with the server.
+ Future<void> _toggleFavorite() async {
+ final media = _media;
+ // Guard against concurrent taps and against toggling before data is loaded.
+ if (media == null || _isFavoriteLoading) return;
+
+ setState(() => _isFavoriteLoading = true);
+
+ // Optimistically flip the favourite flag in local state so the icon
+ // updates instantly without waiting for the round-trip.
+ final newFavorite = !media.favorite;
+ if (!mounted) return;
+ setState(() {
+ _media = _buildMediaWithFavorite(media, newFavorite);
+ });
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final confirmed = await client.toggleFavorite(media.id);
+ if (!mounted) return;
+ // Reconcile with the value the server actually stored.
+ setState(() {
+ _media = _buildMediaWithFavorite(_media!, confirmed);
+ });
+ } catch (e) {
+ if (!mounted) return;
+ // Revert the optimistic update on failure.
+ setState(() {
+ _media = _buildMediaWithFavorite(_media!, media.favorite);
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Could not update favourite. Try again.')),
+ );
+ } finally {
+ if (mounted) setState(() => _isFavoriteLoading = false);
+ }
+ }
+
+ /// Returns a copy of [media] with [favorite] replaced.
+ ///
+ /// [Media] is immutable so we reconstruct it via [Media.fromJson]/[toJson]
+ /// to avoid adding a `copyWith` method to the model layer.
+ Media _buildMediaWithFavorite(Media media, bool favorite) {
+ final json = media.toJson()..['favorite'] = favorite;
+ return Media.fromJson(json);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Navigation
+ // ---------------------------------------------------------------------------
+
+ /// Routes to the video or audio player based on [media.type].
+ ///
+ /// The stream URL is obtained via [PlayerApiClient.streamUrl] — keeping the
+ /// API path in one place and preventing Dio internals from leaking into the
+ /// UI layer (Dependency Inversion). The URL is passed as a route extra so
+ /// the player screen can start playback without a second API call.
+ void _play() {
+ final media = _media;
+ if (media == null) return;
+
+ final client = ref.read(apiClientProvider);
+ // Delegate URL construction to the client; avoids coupling the screen to
+ // the underlying Dio base URL or request structure.
+ final streamUrl = client.streamUrl(media.id);
+
+ if (media.type == 'video') {
+ context.go(
+ AppRoutes.videoPlayerPath(media.id.toString()),
+ extra: streamUrl,
+ );
+ } else {
+ // audio / podcast / unknown — default to the audio player.
+ context.go(
+ AppRoutes.audioPlayerPath(media.id.toString()),
+ extra: streamUrl,
+ );
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
- appBar: AppBar(title: Text('Media $mediaId')),
- body: Center(child: Text('Media detail for ID $mediaId – placeholder')),
+ appBar: _buildAppBar(),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Builds the app bar; shows the media title when available.
+ AppBar _buildAppBar() {
+ return AppBar(
+ title: Text(_media?.fileName ?? 'Media ${widget.mediaId}'),
+ );
+ }
+
+ /// Delegates to the appropriate state widget based on loading/error/data.
+ Widget _buildBody(BuildContext context) {
+ // Full-screen spinner only on the very first load (no data yet).
+ if (_isLoading && _media == null) {
+ return const Center(
+ key: Key('media_detail_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (_error != null) {
+ return _ErrorView(
+ message: _error!,
+ onRetry: _load,
+ );
+ }
+
+ if (_media == null) {
+ // Should not happen in normal flow, but guard defensively.
+ return const SizedBox.shrink();
+ }
+
+ return RefreshIndicator(
+ onRefresh: _load,
+ child: _MediaDetailContent(
+ media: _media!,
+ thumbnailUrl: ref.read(apiClientProvider).thumbnailUrl(_media!.id),
+ onFavoriteToggle: _toggleFavorite,
+ onPlay: _play,
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _MediaDetailContent
+// ---------------------------------------------------------------------------
+
+/// Scrollable body of the media detail screen.
+///
+/// Extracted from [_MediaDetailScreenState] so the state class stays concise
+/// and this widget is independently testable. All callbacks are injected so
+/// this widget has no direct dependency on providers or navigation
+/// (Dependency Inversion, Single Responsibility).
+class _MediaDetailContent extends StatelessWidget {
+ const _MediaDetailContent({
+ required this.media,
+ required this.thumbnailUrl,
+ required this.onFavoriteToggle,
+ required this.onPlay,
+ });
+
+ final Media media;
+
+ /// Pre-computed thumbnail URL so this widget stays provider-free.
+ final String thumbnailUrl;
+
+ /// Called when the favourite icon button is tapped.
+ final VoidCallback onFavoriteToggle;
+
+ /// Called when the play button is tapped.
+ final VoidCallback onPlay;
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Full-width thumbnail/cover image.
+ _ThumbnailBanner(thumbnailUrl: thumbnailUrl, type: media.type),
+
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Title + favourite toggle on the same row.
+ _TitleRow(
+ title: media.fileName,
+ isFavorite: media.favorite,
+ onFavoriteToggle: onFavoriteToggle,
+ ),
+
+ const SizedBox(height: 8),
+
+ // Codec · resolution · duration · file size.
+ _MetadataRow(media: media),
+
+ // Tag chips (hidden when no tags).
+ if (media.tags.isNotEmpty) ...[
+ const SizedBox(height: 12),
+ _TagChips(tags: media.tags),
+ ],
+
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+
+ // Play button anchored at the bottom of the scrollable area.
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: _PlayButton(type: media.type, onPlay: onPlay),
+ ),
+
+ const SizedBox(height: 24),
+ ],
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _ThumbnailBanner
+// ---------------------------------------------------------------------------
+
+/// Full-width hero image at the top of the detail screen.
+///
+/// Falls back to an icon placeholder when [thumbnailUrl] is empty or the
+/// network request fails — mirrors the card thumbnail pattern from
+/// [MediaGridScreen] for visual consistency.
+class _ThumbnailBanner extends StatelessWidget {
+ const _ThumbnailBanner({required this.thumbnailUrl, required this.type});
+
+ final String thumbnailUrl;
+
+ /// Media type string used to choose the placeholder icon.
+ final String type;
+
+ @override
+ Widget build(BuildContext context) {
+ return AspectRatio(
+ // 16:9 for video; square-ish (4:3) for audio/other for visual variety.
+ aspectRatio: type == 'video' ? 16 / 9 : 4 / 3,
+ child: thumbnailUrl.isEmpty
+ ? _placeholder(context)
+ : CachedNetworkImage(
+ key: const Key('media_detail_thumbnail'),
+ imageUrl: thumbnailUrl,
+ fit: BoxFit.cover,
+ placeholder: (_, __) =>
+ const Center(child: CircularProgressIndicator()),
+ errorWidget: (_, __, ___) => _placeholder(context),
+ ),
+ );
+ }
+
+ /// Colored box with a type-appropriate icon when no thumbnail is available.
+ Widget _placeholder(BuildContext context) {
+ final icon = type == 'video'
+ ? Icons.videocam_outlined
+ : type == 'audio'
+ ? Icons.headphones_outlined
+ : Icons.image_outlined;
+
+ return ColoredBox(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ child: Icon(
+ icon,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _TitleRow
+// ---------------------------------------------------------------------------
+
+/// Row containing the media title and a favourite toggle icon button.
+///
+/// The favourite icon is filled when [isFavorite] is true, outlined otherwise.
+/// Tapping calls [onFavoriteToggle] — the actual API call and state update are
+/// handled by the parent state class.
+class _TitleRow extends StatelessWidget {
+ const _TitleRow({
+ required this.title,
+ required this.isFavorite,
+ required this.onFavoriteToggle,
+ });
+
+ final String title;
+ final bool isFavorite;
+ final VoidCallback onFavoriteToggle;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ title,
+ key: const Key('media_detail_title'),
+ style: Theme.of(context).textTheme.titleLarge,
+ ),
+ ),
+ IconButton(
+ key: const Key('media_detail_favorite'),
+ icon: Icon(
+ isFavorite ? Icons.favorite : Icons.favorite_border,
+ color: isFavorite
+ ? Theme.of(context).colorScheme.error
+ : Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ tooltip: isFavorite ? 'Remove from favourites' : 'Add to favourites',
+ onPressed: onFavoriteToggle,
+ ),
+ ],
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _MetadataRow
+// ---------------------------------------------------------------------------
+
+/// Horizontal row of codec · resolution · duration · file-size chips.
+///
+/// Renders each non-empty value as a compact text badge separated by a
+/// centred dot divider. Empty or zero values are omitted to avoid noise
+/// (e.g. audio items have no meaningful resolution).
+class _MetadataRow extends StatelessWidget {
+ const _MetadataRow({required this.media});
+
+ final Media media;
+
+ @override
+ Widget build(BuildContext context) {
+ final parts = _buildParts();
+ if (parts.isEmpty) return const SizedBox.shrink();
+
+ return Wrap(
+ key: const Key('media_detail_metadata'),
+ spacing: 4,
+ runSpacing: 4,
+ children: [
+ for (int i = 0; i < parts.length; i++) ...[
+ if (i > 0)
+ Text(
+ '·',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ Text(
+ parts[i],
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ );
+ }
+
+ /// Collects non-empty metadata strings to display.
+ List<String> _buildParts() {
+ final parts = <String>[];
+ if (media.codec.isNotEmpty) parts.add(media.codec);
+ if (media.resolution.isNotEmpty) parts.add(media.resolution);
+ if (media.duration > 0) parts.add(_formatDuration(media.duration));
+ if (media.fileSizeBytes > 0) parts.add(_formatFileSize(media.fileSizeBytes));
+ return parts;
+ }
+
+ /// Formats [seconds] as `h:mm:ss` or `m:ss`.
+ 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')}';
+ }
+
+ /// Formats [bytes] as a human-readable size string (KB, MB, GB).
+ static String _formatFileSize(int bytes) {
+ if (bytes >= 1073741824) {
+ return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
+ }
+ if (bytes >= 1048576) {
+ return '${(bytes / 1048576).toStringAsFixed(1)} MB';
+ }
+ return '${(bytes / 1024).toStringAsFixed(0)} KB';
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _TagChips
+// ---------------------------------------------------------------------------
+
+/// Horizontally wrapping row of tag chips.
+///
+/// Uses [Chip] (non-interactive, display-only) rather than [FilterChip]
+/// because the detail screen does not filter — it merely shows what tags
+/// are attached to the item.
+class _TagChips extends StatelessWidget {
+ const _TagChips({required this.tags});
+
+ final List<String> tags;
+
+ @override
+ Widget build(BuildContext context) {
+ return Wrap(
+ key: const Key('media_detail_tags'),
+ spacing: 8,
+ runSpacing: 4,
+ children: [
+ for (final tag in tags)
+ Chip(
+ label: Text(tag),
+ labelStyle: Theme.of(context).textTheme.labelSmall,
+ padding: EdgeInsets.zero,
+ visualDensity: VisualDensity.compact,
+ ),
+ ],
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _PlayButton
+// ---------------------------------------------------------------------------
+
+/// Full-width play button at the bottom of the detail screen.
+///
+/// Shows a video or audio icon depending on [type]. Calls [onPlay] when
+/// tapped; routing to the correct player is the parent's responsibility
+/// (Single Responsibility: this widget only concerns itself with the button
+/// appearance and callback delegation).
+class _PlayButton extends StatelessWidget {
+ const _PlayButton({required this.type, required this.onPlay});
+
+ final String type;
+ final VoidCallback onPlay;
+
+ @override
+ Widget build(BuildContext context) {
+ final isVideo = type == 'video';
+ return FilledButton.icon(
+ key: const Key('media_detail_play'),
+ onPressed: onPlay,
+ icon: Icon(isVideo ? Icons.play_circle_outline : Icons.headphones),
+ label: Text(isVideo ? 'Play Video' : 'Play Audio'),
+ style: FilledButton.styleFrom(
+ minimumSize: const Size.fromHeight(48),
+ ),
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// _ErrorView
+// ---------------------------------------------------------------------------
+
+/// Full-screen error view with a retry button.
+///
+/// Shown when [getMedia] throws. The [message] comes from
+/// [mediaDetailErrorMessage], 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: [
+ Icon(
+ Icons.error_outline,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('media_detail_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('media_detail_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 7353928..e90b9c2 100644
--- a/player-android/lib/utils/error_mappers.dart
+++ b/player-android/lib/utils/error_mappers.dart
@@ -91,3 +91,21 @@ String mediaErrorMessage(Object error) {
}
return 'Unexpected error. Please try again.';
}
+
+/// Maps any thrown object from [PlayerApiClient.getMedia] to a UI string.
+///
+/// Adds a 404-specific message ("Media not found") on top of the generic
+/// connection-error mapping so the detail screen can distinguish between a
+/// missing item and a network/server failure (Open-Closed: isolated from the
+/// list-media helper so either can evolve independently).
+String mediaDetailErrorMessage(Object error) {
+ if (error is DioException) {
+ // Surface a friendly "not found" message for 404 so users know the item
+ // no longer exists rather than seeing a generic server-error message.
+ if (error.response?.statusCode == 404) {
+ return 'Media not found. It may have been deleted.';
+ }
+ return dioConnectionErrorMessage(error);
+ }
+ return 'Unexpected error. Please try again.';
+}
diff --git a/player-android/test/screens/media_detail_screen_test.dart b/player-android/test/screens/media_detail_screen_test.dart
new file mode 100644
index 0000000..87ece3c
--- /dev/null
+++ b/player-android/test/screens/media_detail_screen_test.dart
@@ -0,0 +1,516 @@
+// Widget tests for MediaDetailScreen (media_detail_screen.dart).
+//
+// Tests cover:
+// 1. Shows a loading indicator while getMedia is in flight.
+// 2. Renders title, metadata row, tag chips, and thumbnail after a
+// successful load.
+// 3. Play button routes to /video/:id for a video item.
+// 4. Play button routes to /audio/:id for an audio item.
+// 5. Favourite toggle button flips the icon and calls toggleFavorite.
+// 6. Shows an error view when getMedia throws a DioException.
+// 7. Retry button triggers a fresh getMedia call.
+// 8. 404 error is mapped to the "not found" message.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/media_detail_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_detail_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 [MediaDetailScreen] tests.
+///
+/// Only [getMedia], [toggleFavorite], and [thumbnailUrl] are implemented;
+/// all other methods remain [UnimplementedError].
+class _FakeApiClient extends PlayerApiClient {
+ _FakeApiClient() : super(dio: Dio());
+
+ /// When non-null, [getMedia] returns this value.
+ Media? mediaResult;
+
+ /// When non-null, [getMedia] throws this instead of returning.
+ Object? mediaError;
+
+ /// When non-null, [toggleFavorite] returns this bool.
+ bool? toggleResult;
+
+ /// When non-null, [toggleFavorite] throws this instead of returning.
+ Object? toggleError;
+
+ /// Records how many times [getMedia] was called.
+ int getMediaCallCount = 0;
+
+ /// Records how many times [toggleFavorite] was called.
+ int toggleCallCount = 0;
+
+ @override
+ Future<Media> getMedia(int mediaId) async {
+ getMediaCallCount++;
+ if (mediaError != null) throw mediaError!;
+ return mediaResult!;
+ }
+
+ @override
+ Future<bool> toggleFavorite(int mediaId) async {
+ toggleCallCount++;
+ if (toggleError != null) throw toggleError!;
+ return toggleResult!;
+ }
+
+ /// Returns an empty string so [_ThumbnailBanner] shows the static
+ /// placeholder instead of making a network request — keeps tests hermetic.
+ @override
+ String thumbnailUrl(int mediaId) => '';
+}
+
+/// [PlayerApiClient] stub that delays [getMedia] 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<Media>();
+
+ /// Resolves the pending [getMedia] call with [media].
+ void complete(Media media) => _completer.complete(media);
+
+ @override
+ Future<Media> getMedia(int mediaId) => _completer.future;
+
+ @override
+ String thumbnailUrl(int mediaId) => '';
+}
+
+// ---------------------------------------------------------------------------
+// Sample data
+// ---------------------------------------------------------------------------
+
+/// A sample video item used across tests.
+const _kVideo = Media(
+ id: 42,
+ setId: 10,
+ relPath: 'movies/action/hero.mp4',
+ fileName: 'hero.mp4',
+ absPath: '/media/movies/action/hero.mp4',
+ type: 'video',
+ duration: 7320.0, // 2h 2m
+ codec: 'h264/aac',
+ resolution: '1920x1080',
+ bitrate: 4500,
+ fileSizeBytes: 1073741824, // 1 GiB
+ width: 1920,
+ height: 1080,
+ thumbnailPath: '/media/.thumbs/hero.jpg',
+ playCount: 3,
+ favorite: false,
+ tags: ['action', 'english'],
+);
+
+/// A sample audio item used across tests.
+const _kAudio = Media(
+ id: 7,
+ setId: 5,
+ 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,
+ favorite: true,
+ tags: [],
+);
+
+// ---------------------------------------------------------------------------
+// Helper: pump MediaDetailScreen inside a minimal ProviderScope + GoRouter
+// ---------------------------------------------------------------------------
+
+/// Key used by the navigation-destination stub route.
+const _kDestinationKey = Key('nav_destination');
+
+/// Builds a [GoRouter] with [MediaDetailScreen] at `/media/:id` and stub
+/// routes at `/video/:mediaId` and `/audio/:mediaId` for navigation tests.
+GoRouter _buildRouter(PlayerApiClient fakeClient, String mediaId) {
+ return GoRouter(
+ initialLocation: '/media/$mediaId',
+ routes: [
+ GoRoute(
+ path: '/media/:id',
+ builder: (context, state) =>
+ MediaDetailScreen(mediaId: state.pathParameters['id']!),
+ ),
+ GoRoute(
+ path: '/video/:mediaId',
+ builder: (context, state) => Scaffold(
+ body: Text(
+ 'Video ${state.pathParameters['mediaId']}',
+ key: _kDestinationKey,
+ ),
+ ),
+ ),
+ GoRoute(
+ path: '/audio/:mediaId',
+ builder: (context, state) => Scaffold(
+ body: Text(
+ 'Audio ${state.pathParameters['mediaId']}',
+ key: _kDestinationKey,
+ ),
+ ),
+ ),
+ ],
+ );
+}
+
+/// Pumps [MediaDetailScreen] for [mediaId] inside a [ProviderScope] with
+/// overridden providers, backed by a [GoRouter] for navigation tests.
+Future<void> _pumpScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient, {
+ String mediaId = '42',
+}) async {
+ final router = _buildRouter(fakeClient, mediaId);
+ 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 a loading indicator while getMedia 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();
+
+ expect(
+ find.byKey(const Key('media_detail_loading')),
+ findsOneWidget,
+ );
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+ // Resolve to prevent "pending async work" warnings.
+ fakeClient.complete(_kVideo);
+ await tester.pumpAndSettle();
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Successful render
+ // --------------------------------------------------------------------------
+
+ group('successful render', () {
+ testWidgets('renders title after a successful load', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kVideo;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_detail_title')), findsOneWidget);
+ expect(find.text('hero.mp4'), findsWidgets);
+ });
+
+ testWidgets('renders metadata row with codec, resolution, duration, size',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kVideo;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_detail_metadata')), findsOneWidget);
+ // Codec.
+ expect(find.textContaining('h264/aac'), findsOneWidget);
+ // Resolution.
+ expect(find.textContaining('1920x1080'), findsOneWidget);
+ // Duration: 7320 s = 2h 2m 0s → "2:02:00".
+ expect(find.textContaining('2:02:00'), findsOneWidget);
+ // File size: 1 GiB → "1.0 GB".
+ expect(find.textContaining('1.0 GB'), findsOneWidget);
+ });
+
+ testWidgets('renders tag chips when tags are present', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kVideo;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_detail_tags')), findsOneWidget);
+ expect(find.text('action'), findsOneWidget);
+ expect(find.text('english'), findsOneWidget);
+ });
+
+ testWidgets('hides tag chips row when tags list is empty', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kAudio;
+
+ await _pumpScreen(tester, fakeClient, mediaId: '7');
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_detail_tags')), findsNothing);
+ });
+
+ testWidgets('renders play button', (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kVideo;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_detail_play')), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Play button routing
+ // --------------------------------------------------------------------------
+
+ group('play button routing', () {
+ testWidgets('tapping play on a video item routes to /video/:id',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..mediaResult = _kVideo;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // The play button may be below the visible area in the test viewport;
+ // scroll it into view before tapping.
+ await tester.ensureVisible(find.byKey(const Key('media_detail_play')));
+ await tester.tap(find.byKey(const Key('media_detail_play')));
+ await tester.pumpAndSettle();
+
+ // The stub route at /video/:mediaId must be visible.