diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 18:21:56 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 18:21:56 +0300 |
| commit | 627f44e105926e5ad4963336cdbfd9f9522bcdb1 (patch) | |
| tree | 50878446d6770361d4b755a21954da0052afd072 | |
| parent | 753dbb942a6c5c98db4b9048c7dd80ca7ec866ee (diff) | |
Implement ContinueWatchingScreen with resume cards and progress routing (2b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 13 | ||||
| -rw-r--r-- | player-android/lib/app_routes.dart | 11 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 47 | ||||
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 15 | ||||
| -rw-r--r-- | player-android/lib/screens/continue_watching_screen.dart | 483 | ||||
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 15 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 13 | ||||
| -rw-r--r-- | player-android/test/screens/continue_watching_screen_test.dart | 483 |
8 files changed, 1064 insertions, 16 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index a1ed037..f591608 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -267,6 +267,19 @@ class DioPlayerApiClient extends PlayerApiClient { // Progress // --------------------------------------------------------------------------- + /// Returns all media items the authenticated user has started but not finished. + /// + /// GET /api/v1/in-progress — returns the same [Media] array as GET /api/v1/media. + /// The caller (ContinueWatchingScreen) uses this to populate the resume list. + @override + Future<List<Media>> listInProgress() async { + final response = await rawDio.get<List<dynamic>>('$_kApiV1/in-progress'); + return (response.data ?? []) + .cast<Map<String, dynamic>>() + .map(Media.fromJson) + .toList(); + } + /// Returns the last saved playback position for [mediaId], or `null`. /// /// GET /api/v1/media/{id} — extracts the `progress.position_seconds` field diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index bc2fda1..eefda4b 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -29,6 +29,9 @@ abstract final class AppRoutes { /// Opens [PodcastListScreen] and supports the SubscribeDialog FAB. static const podcasts = '/podcasts'; + /// Route that shows the Continue Watching screen (in-progress media items). + static const continueWatching = '/continue'; + /// Returns the concrete path for a media-detail page given a numeric [id]. static String mediaDetailPath(int id) => '/media/$id'; @@ -40,4 +43,12 @@ abstract final class AppRoutes { /// Returns the concrete path for the audio player of a given [mediaId]. static String audioPlayerPath(String mediaId) => '/audio/$mediaId'; + + /// Returns the appropriate player path for [type] and [mediaId]. + /// + /// Centralises the audio-vs-video routing decision so call-sites do not need + /// to repeat the same if/else. Audio maps to [audioPlayerPath]; every other + /// type (including 'video' and unknown) maps to [videoPlayerPath]. + static String playerPathForType(String type, String mediaId) => + type == 'audio' ? audioPlayerPath(mediaId) : videoPlayerPath(mediaId); } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 2a44c16..617a7f1 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -8,6 +8,7 @@ import 'providers/auth_state_provider.dart'; import 'providers/first_run_provider.dart'; import 'screens/audio_player_screen.dart'; import 'screens/bootstrap_screen.dart'; +import 'screens/continue_watching_screen.dart'; import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; import 'screens/media_detail_screen.dart'; @@ -104,6 +105,11 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const SetsListScreen(), ), GoRoute( + // Continue-watching screen — lists all in-progress media items. + path: AppRoutes.continueWatching, + builder: (context, state) => const ContinueWatchingScreen(), + ), + GoRoute( path: AppRoutes.mediaGrid, builder: (context, state) { // The ':setId' path parameter is guaranteed by the route pattern. @@ -143,11 +149,12 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) { // ':mediaId' is guaranteed present by the route pattern. final mediaId = state.pathParameters['mediaId']!; - // The resolved stream URL is optionally forwarded as a route extra - // (String) by the calling screen (e.g. MediaDetailScreen). - final mediaUrl = - state.extra is String ? state.extra as String : null; - return VideoPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl); + final (mediaUrl, startPosition) = _parsePlayerExtra(state.extra); + return VideoPlayerScreen( + mediaId: mediaId, + mediaUrl: mediaUrl, + startPosition: startPosition, + ); }, ), GoRoute( @@ -155,11 +162,12 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) { // ':mediaId' is guaranteed present by the route pattern. final mediaId = state.pathParameters['mediaId']!; - // The resolved stream URL is optionally forwarded as a route extra - // (String) by the calling screen (e.g. MediaDetailScreen). - final mediaUrl = - state.extra is String ? state.extra as String : null; - return AudioPlayerScreen(mediaId: mediaId, mediaUrl: mediaUrl); + final (mediaUrl, startPosition) = _parsePlayerExtra(state.extra); + return AudioPlayerScreen( + mediaId: mediaId, + mediaUrl: mediaUrl, + startPosition: startPosition, + ); }, ), ], @@ -170,6 +178,25 @@ final routerProvider = Provider<GoRouter>((ref) { // Internal helpers // --------------------------------------------------------------------------- +/// Parses the route [extra] passed to video and audio player routes. +/// +/// Accepts two shapes forwarded by different call-sites: +/// - `Map<String, dynamic>`: `{mediaUrl: String, position: double}` from +/// [ContinueWatchingScreen] so the player can seek immediately without an +/// extra [getMediaProgress] round-trip. +/// - `String`: plain stream URL forwarded by [MediaDetailScreen]. +/// +/// Returns a record `(mediaUrl, startPosition)` with null for absent values. +/// Extracted to avoid duplicating this logic across the video and audio routes. +(String?, double?) _parsePlayerExtra(Object? extra) { + if (extra is Map<String, dynamic>) { + final mediaUrl = extra['mediaUrl'] as String?; + final startPosition = (extra['position'] as num?)?.toDouble(); + return (mediaUrl, startPosition); + } + return (extra is String ? extra : null, null); +} + /// Bridges Riverpod's auth and first-run providers to [GoRouter.refreshListenable]. /// /// GoRouter expects a [ChangeNotifier] (or any [Listenable]) for its refresh diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart index 7aa49de..00465e4 100644 --- a/player-android/lib/screens/audio_player_screen.dart +++ b/player-android/lib/screens/audio_player_screen.dart @@ -44,6 +44,7 @@ class AudioPlayerScreen extends ConsumerStatefulWidget { super.key, required this.mediaId, this.mediaUrl, + this.startPosition, }); /// The media item identifier extracted from the '/audio/:mediaId' route path. @@ -54,6 +55,11 @@ class AudioPlayerScreen extends ConsumerStatefulWidget { /// base URL stays in a single place (Dependency Inversion Principle). final String? mediaUrl; + /// Optional start position in seconds, forwarded from the continue-watching + /// screen to resume at the saved position without an extra API round-trip. + /// When null, [PlayerApiClient.getMediaProgress] is called instead. + final double? startPosition; + @override ConsumerState<AudioPlayerScreen> createState() => _AudioPlayerScreenState(); } @@ -155,10 +161,13 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { return; } - // Step 4: resume from the server-saved position (best-effort; ignore - // errors so a missing progress row never blocks playback). + // Step 4: resume from the saved position. + // Prefer [widget.startPosition] (forwarded by the continue-watching screen) + // to avoid a redundant API round-trip. Fall back to [getMediaProgress] so + // audio items opened from other screens still resume correctly. try { - final savedSeconds = await client.getMediaProgress(mediaIdInt); + final savedSeconds = + widget.startPosition ?? await client.getMediaProgress(mediaIdInt); if (savedSeconds != null && savedSeconds > 0) { await audioPlayer.seek( Duration(milliseconds: (savedSeconds * 1000).round()), diff --git a/player-android/lib/screens/continue_watching_screen.dart b/player-android/lib/screens/continue_watching_screen.dart new file mode 100644 index 0000000..9153e01 --- /dev/null +++ b/player-android/lib/screens/continue_watching_screen.dart @@ -0,0 +1,483 @@ +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'; + +import '../api/player_api_client.dart'; +import '../app_routes.dart'; +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +/// Continue Watching screen: lists all media items the authenticated user has +/// started but not finished, with a thumbnail, title, type icon, and duration. +/// +/// Design notes: +/// - [ConsumerStatefulWidget] is used so we can manage local loading and +/// error state, guard async continuations on [mounted], and call +/// [setState] to trigger rebuilds (mirrors [SetsListScreen] patterns). +/// - [listInProgress] is called from [initState] and again on +/// pull-to-refresh. The result is stored locally rather than in a +/// Riverpod notifier because this screen owns its full lifecycle. +/// - Error handling uses the top-level [continueWatchingErrorMessage] helper +/// from error_mappers.dart (DIP — no Dio import in this file). +/// - Tapping a card routes to `/video/:mediaId` or `/audio/:mediaId` with a +/// [Map] extra carrying `{mediaUrl, position}` so the player can seek to +/// the saved position without an extra API round-trip. +class ContinueWatchingScreen extends ConsumerStatefulWidget { + const ContinueWatchingScreen({super.key}); + + @override + ConsumerState<ContinueWatchingScreen> createState() => + _ContinueWatchingScreenState(); +} + +class _ContinueWatchingScreenState + extends ConsumerState<ContinueWatchingScreen> { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List<Media>? _items; + + // 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 all in-progress media items and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Errors are mapped by the + /// top-level [continueWatchingErrorMessage] helper so this method stays simple. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final client = ref.read(apiClientProvider); + final items = await client.listInProgress(); + if (!mounted) return; + setState(() { + _items = items; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = continueWatchingErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(context), + ); + } + + /// Builds the app bar with title. + AppBar _buildAppBar() { + return AppBar( + title: const Text('Continue Watching'), + ); + } + + /// Builds the main body, delegating to the appropriate state widget: + /// - Loading spinner (first load, before any data arrives). + /// - Error view with a retry button. + /// - Empty-state message when the server returns an empty list. + /// - List of resume 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 && _items == null) { + return const Center( + key: Key('continue_watching_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 list and the empty-state view. + // Read client once here so sub-widgets can build authenticated URLs + // (e.g. thumbnailUrl) without re-reading the provider on every rebuild. + final client = ref.read(apiClientProvider); + return RefreshIndicator( + onRefresh: _load, + child: _items == null || _items!.isEmpty + ? const _EmptyView() + : _ResumeList(items: _items!, client: client, onTap: _onCardTap), + ); + } + + // --------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------- + + /// Navigates to the appropriate player screen for [item]. + /// + /// Passes a [Map] extra with `mediaUrl` and `position` so the player can + /// seek to the saved position without a separate [getMediaProgress] call + /// (avoids an extra API round-trip per resume action). + /// + /// [getMediaProgress] is called here to obtain the saved position. A null + /// result (no progress row) starts the player from the beginning. + Future<void> _onCardTap(Media item) async { + final client = ref.read(apiClientProvider); + final mediaId = item.id; + final mediaUrl = client.streamUrl(mediaId); + + // Fetch saved position best-effort; null means "start from beginning". + double? position; + try { + position = await client.getMediaProgress(mediaId); + } catch (_) { + // Position fetch failure is non-fatal; the player starts from the start. + } + + if (!mounted) return; + + // Pass both the stream URL and the saved position so the player can seek + // immediately without a second round-trip to the server. + final extra = <String, dynamic>{ + 'mediaUrl': mediaUrl, + if (position != null) 'position': position, + }; + + final mediaIdStr = mediaId.toString(); + // Delegate audio-vs-video path selection to the centralised helper so this + // call-site does not duplicate the routing logic (OCP). + context.go(AppRoutes.playerPathForType(item.type, mediaIdStr), extra: extra); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable list of resume cards. +/// +/// Extracted into its own stateless widget so [_ContinueWatchingScreenState] +/// stays small and the list layout is independently testable. +class _ResumeList extends StatelessWidget { + const _ResumeList({ + required this.items, + required this.client, + required this.onTap, + }); + + final List<Media> items; + + /// API client forwarded to each card so thumbnails use authenticated URLs. + final PlayerApiClient client; + + /// Called with the tapped [Media] item; the parent handles navigation. + final void Function(Media) onTap; + + @override + Widget build(BuildContext context) { + return ListView.builder( + key: const Key('continue_watching_list'), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) => _ResumeCard( + item: items[index], + client: client, + onTap: onTap, + ), + ); + } +} + +/// A single resume card showing thumbnail, title, type icon, and duration. +/// +/// Tapping the card delegates navigation back to [_ContinueWatchingScreenState] +/// via [onTap] (Single Responsibility — this widget is purely presentational). +class _ResumeCard extends StatelessWidget { + const _ResumeCard({ + required this.item, + required this.client, + required this.onTap, + }); + + final Media item; + + /// API client used to build the authenticated thumbnail URL. + final PlayerApiClient client; + + final void Function(Media) onTap; + + @override + Widget build(BuildContext context) { + return Card( + key: Key('resume_card_${item.id}'), + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => onTap(item), + child: Row( + children: [ + // Thumbnail on the left: fixed 100×75 px, using the authenticated + // API endpoint rather than the raw relative server path. + _Thumbnail(item: item, client: client), + // Title, type icon and duration on the right. + Expanded(child: _CardDetails(item: item)), + ], + ), + ), + ); + } +} + +/// Left-side thumbnail for a resume card. +/// +/// Displays the media thumbnail via [CachedNetworkImage] with a placeholder +/// and error fallback, keeping re-download count low across rebuilds. +/// +/// Uses [client.thumbnailUrl] (the authenticated API endpoint) rather than +/// [Media.thumbnailPath] (a relative server path that is not a valid URL). +class _Thumbnail extends StatelessWidget { + const _Thumbnail({required this.item, required this.client}); + + final Media item; + + /// API client used to build the authenticated thumbnail URL. + final PlayerApiClient client; + + @override + Widget build(BuildContext context) { + // Use the authenticated thumbnail endpoint instead of the raw relative path. + final url = client.thumbnailUrl(item.id); + + // Skip the network request entirely when the URL is empty (server has not + // generated a thumbnail yet) and fall back to the placeholder icon. + if (url.isEmpty) return _placeholder(context); + + return SizedBox( + width: 100, + height: 75, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + width: 100, + height: 75, + placeholder: (_, __) => _loadingWidget(), + errorWidget: (_, __, ___) => _placeholder(context), + ), + ); + } + + static Widget _loadingWidget() => const SizedBox( + width: 100, + height: 75, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + + static Widget _placeholder(BuildContext context) => SizedBox( + width: 100, + height: 75, + child: ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Icon( + Icons.movie_outlined, + size: 36, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); +} + +/// Right-side details: title, type icon badge, and total duration. +/// +/// Shows a video/audio icon badge next to the title so the user can identify +/// the media type at a glance without relying on colour alone. +class _CardDetails extends StatelessWidget { + const _CardDetails({required this.item}); + + final Media item; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Title row with type icon badge. + Row( + children: [ + _TypeIcon(type: item.type), + const SizedBox(width: 6), + Expanded( + child: Text( + item.fileName, + key: Key('resume_card_title_${item.id}'), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + // Total duration: provides a rough "how much is left" sense even + // without the saved position in the list response. + Text( + _formatDuration(item.duration), + key: Key('resume_card_duration_${item.id}'), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} + +/// Formats [durationSeconds] as `mm:ss` or `h:mm:ss`. +/// +/// Top-level function (consistent with the audio player screen pattern) so it +/// can be reused without an instance and is easy to unit-test in isolation. +String _formatDuration(double durationSeconds) { + final d = Duration(milliseconds: (durationSeconds * 1000).round()); + final h = d.inHours; + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; +} + +/// Small type-icon badge for video or audio items. +/// +/// Uses [Icons.videocam_outlined] for video and [Icons.headphones] for audio, +/// falling back to [Icons.play_circle_outline] for unknown types. +class _TypeIcon extends StatelessWidget { + const _TypeIcon({required this.type}); + + final String type; + + @override + Widget build(BuildContext context) { + final icon = switch (type) { + 'audio' => Icons.headphones, + 'video' => Icons.videocam_outlined, + _ => Icons.play_circle_outline, + }; + return Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.primary, + ); + } +} + +/// Full-screen empty-state view shown when [listInProgress] returns an empty list. +/// +/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the +/// [RefreshIndicator] can still trigger pull-to-refresh with no content. +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.play_circle_outline, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'Nothing in progress', + key: const Key('continue_watching_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Start playing something and it will appear here.', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ); + } +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [listInProgress] throws (network error, server error, etc.). +/// The [message] comes from [continueWatchingErrorMessage]. +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('continue_watching_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('continue_watching_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart index 311e51f..b3247a3 100644 --- a/player-android/lib/screens/video_player_screen.dart +++ b/player-android/lib/screens/video_player_screen.dart @@ -36,6 +36,7 @@ class VideoPlayerScreen extends ConsumerStatefulWidget { super.key, required this.mediaId, this.mediaUrl, + this.startPosition, }); /// The media item identifier extracted from the '/video/:mediaId' route path. @@ -46,6 +47,11 @@ class VideoPlayerScreen extends ConsumerStatefulWidget { /// base URL stays in a single place (Dependency Inversion Principle). final String? mediaUrl; + /// Optional start position in seconds, forwarded from the continue-watching + /// screen to resume at the saved position without an extra API round-trip. + /// When null, [PlayerApiClient.getMediaProgress] is called instead. + final double? startPosition; + @override ConsumerState<VideoPlayerScreen> createState() => _VideoPlayerScreenState(); } @@ -145,10 +151,13 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> { return; } - // Step 4: resume from the server-saved position (best-effort; ignore - // errors so a missing progress row never blocks playback). + // Step 4: resume from the saved position. + // Prefer [widget.startPosition] (forwarded by the continue-watching screen) + // to avoid a redundant API round-trip. Fall back to [getMediaProgress] so + // videos opened from other screens still resume correctly. try { - final savedSeconds = await client.getMediaProgress(mediaIdInt); + final savedSeconds = + widget.startPosition ?? await client.getMediaProgress(mediaIdInt); if (savedSeconds != null && savedSeconds > 0) { await videoController.seekTo( Duration(milliseconds: (savedSeconds * 1000).round()), diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 6ab9731..2d6f446 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -145,6 +145,19 @@ String podcastListErrorMessage(Object error) { return 'Unexpected error. Please try again.'; } +/// Maps any thrown object from [PlayerApiClient.listInProgress] to a UI string. +/// +/// Delegates to [dioConnectionErrorMessage] for [DioException]; returns a +/// generic fallback for all other exception types. Kept as a separate function +/// (Open-Closed) so it can evolve independently — for example, adding a 401 +/// message if session refresh is needed in a future iteration. +String continueWatchingErrorMessage(Object error) { + if (error is DioException) { + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + /// Maps any thrown object from [PlayerApiClient.subscribePodcast] to a UI string. /// /// Adds human-readable messages for the common failure modes: diff --git a/player-android/test/screens/continue_watching_screen_test.dart b/player-android/test/screens/continue_watching_screen_test.dart new file mode 100644 index 0000000..f2ec15a --- /dev/null +++ b/player-android/test/screens/continue_watching_screen_test.dart @@ -0,0 +1,483 @@ +// Widget tests for ContinueWatchingScreen (continue_watching_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while listInProgress is in flight. +// 2. Renders resume cards after a successful load (title, duration keys). +// 3. Shows the correct type icon for video and audio items. +// 4. Empty-state widget when listInProgress returns []. +// 5. Error view when listInProgress throws a DioException. +// 6. Retry button calls listInProgress again. +// 7. Pull-to-refresh calls listInProgress a second time. +// 8. Tapping a video card routes to /video/:id. +// 9. Tapping an audio card routes to /audio/:id. +// 10. continueWatchingErrorMessage unit tests. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/continue_watching_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/continue_watching_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain. +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 [ContinueWatchingScreen] tests. +/// +/// Only [listInProgress], [streamUrl], and [getMediaProgress] are implemented; +/// all other methods remain [UnimplementedError] — the screen calls only these. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [listInProgress] returns this list. + List<Media>? inProgressResult; + + /// When non-null, [listInProgress] throws this instead of returning. + Object? inProgressError; + + /// Number of times [listInProgress] has been called; useful for refresh tests. + int listInProgressCallCount = 0; + + @override + Future<List<Media>> listInProgress() async { + listInProgressCallCount++; + if (inProgressError != null) throw inProgressError!; + return inProgressResult!; + } + + /// Returns a stable fake stream URL so navigation assertions can verify the + /// expected path without real network calls. + @override + String streamUrl(int mediaId) => 'http://fake/stream/$mediaId'; + + /// Returns an empty string so [_Thumbnail] skips the network request in tests. + @override + String thumbnailUrl(int mediaId) => ''; + + /// Returns `null` so the player starts from the beginning in tests. + @override + Future<double?> getMediaProgress(int mediaId) async => null; +} + +/// [PlayerApiClient] stub that delays [listInProgress] 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 [listInProgress] call with [items]. + void complete(List<Media> items) => _completer.complete(items); + + @override + Future<List<Media>> listInProgress() => _completer.future; + + @override + String streamUrl(int mediaId) => 'http://fake/stream/$mediaId'; + + /// Returns an empty string so [_Thumbnail] skips the network request in tests. + @override + String thumbnailUrl(int mediaId) => ''; + + @override + Future<double?> getMediaProgress(int mediaId) async => null; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A video media item. +const _kVideo = Media( + id: 1, + setId: 10, + relPath: 'movies/film.mp4', + fileName: 'film.mp4', + absPath: '/media/movies/film.mp4', + type: 'video', + duration: 7200.0, + codec: 'h264/aac', + resolution: '1920x1080', + bitrate: 4500, + fileSizeBytes: 1024, + width: 1920, + height: 1080, + thumbnailPath: '', + playCount: 1, +); + +/// An audio media item. +const _kAudio = Media( + id: 2, + setId: 11, + relPath: 'podcasts/episode.mp3', + fileName: 'episode.mp3', + absPath: '/media/podcasts/episode.mp3', + type: 'audio', + duration: 3600.0, + codec: 'mp3', + resolution: '', + bitrate: 128, + fileSizeBytes: 512, + width: 0, + height: 0, + thumbnailPath: '', + playCount: 1, +); + +// --------------------------------------------------------------------------- +// Pump helper +// --------------------------------------------------------------------------- + +/// Pumps [ContinueWatchingScreen] inside a [ProviderScope] with overrides. +/// +/// Captures navigated routes via a [GoRouter] stub so tap tests can assert +/// the correct player route was pushed. Captured routes are stored in +/// [_lastNavigatedRoutes] and reset by [setUp] before each test. +Future<void> _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + // Track the last navigated location via a GoRouter with a simple observer. + final navigatedRoutes = <String>[]; + + final router = GoRouter( + initialLocation: '/continue', + routes: [ + GoRoute( + path: '/continue', + builder: (_, __) => const ContinueWatchingScreen(), + ), + // Stub routes so navigation does not crash during tap tests. + GoRoute( + path: '/video/:mediaId', + builder: (_, state) {< |
