diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 23:33:02 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 23:33:02 +0300 |
| commit | 3e0aa0f0eb94d8a43d57a6d257206673a397aac3 (patch) | |
| tree | 1a90f5037af0effdd281f6d53082b7fe8b094888 /player-android | |
| parent | d40a585b5848cc5bfe788c372a2bd1c028b9308d (diff) | |
Implement podcast episode played/unplayed toggle with progress bar (cb)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/app_routes.dart | 17 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 14 | ||||
| -rw-r--r-- | player-android/lib/screens/podcast_episodes_screen.dart | 561 | ||||
| -rw-r--r-- | player-android/lib/screens/podcast_list_screen.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 36 | ||||
| -rw-r--r-- | player-android/test/screens/podcast_episodes_screen_test.dart | 638 |
6 files changed, 1270 insertions, 3 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index 533c9b8..c9207f8 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -77,4 +77,21 @@ abstract final class AppRoutes { /// Returns the concrete path for the notes editor of a given [mediaId]. static String notesPath(String mediaId) => '/notes/$mediaId'; + + /// Route that lists episodes for a single podcast set. + /// + /// The ':setId' path segment is the numeric podcast set identifier. + /// Opens [PodcastEpisodesScreen] where the user can see episode titles, + /// played state, and playback progress. + static const podcastEpisodes = '/podcasts/:setId/episodes'; + + /// Returns the concrete path for the podcast-episodes screen of [setId]. + /// + /// [setName] is optionally passed as a URL query parameter so the app bar + /// can show it without an extra API call. + static String podcastEpisodesPath(int setId, {String? setName}) { + final base = '/podcasts/$setId/episodes'; + if (setName == null || setName.isEmpty) return base; + return '$base?name=${Uri.encodeComponent(setName)}'; + } } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 170cfd5..36cdd50 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -13,6 +13,7 @@ import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; import 'screens/media_detail_screen.dart'; import 'screens/media_grid_screen.dart'; +import 'screens/podcast_episodes_screen.dart'; import 'screens/podcast_list_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; @@ -148,6 +149,19 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const PodcastListScreen(), ), GoRoute( + // Podcast episodes screen — shows all episodes for a podcast set. + // The ':setId' path segment is the numeric podcast set identifier. + // The optional 'name' query parameter provides the feed title for the + // app bar without an extra API round-trip. + path: AppRoutes.podcastEpisodes, + builder: (context, state) { + final raw = state.pathParameters['setId']!; + final setId = int.tryParse(raw) ?? 0; + final setName = state.uri.queryParameters['name']; + return PodcastEpisodesScreen(setId: setId, setName: setName); + }, + ), + GoRoute( path: AppRoutes.videoPlayer, builder: (context, state) { // ':mediaId' is guaranteed present by the route pattern. diff --git a/player-android/lib/screens/podcast_episodes_screen.dart b/player-android/lib/screens/podcast_episodes_screen.dart new file mode 100644 index 0000000..330412e --- /dev/null +++ b/player-android/lib/screens/podcast_episodes_screen.dart @@ -0,0 +1,561 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/duration_formatter.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// _buildEpisodeWithCompleted (file-private helper) +// --------------------------------------------------------------------------- + +/// Returns a copy of [episode] with the [isCompleted] flag replaced. +/// +/// [PodcastEpisode] is immutable, so we rebuild via [PodcastEpisode.fromJson] / +/// [PodcastEpisode.toJson] to avoid coupling this screen to any `copyWith` +/// generated method. Extracted as a file-private function so both +/// [_PodcastEpisodesScreenState] and the episode row can share it without +/// adding a public model API (Dependency Inversion, DRY). +PodcastEpisode _buildEpisodeWithCompleted( + PodcastEpisode episode, + bool isCompleted, +) { + final json = episode.toJson()..['is_completed'] = isCompleted; + return PodcastEpisode.fromJson(json); +} + +/// Screen that lists all episodes for a single podcast set. +/// +/// Each episode row shows the episode title, publication date, duration, and: +/// - A checkmark icon / toggle button reflecting the [PodcastEpisode.isCompleted] +/// (played/unplayed) state. Tapping the icon performs an optimistic update +/// via [toggleEpisodeComplete] and reverts on error. +/// - A linear progress bar below the title showing playback position derived +/// from [PodcastEpisode.positionSeconds] and [PodcastEpisode.durationSeconds] +/// (when the episode has been partially played but not completed). +/// +/// Design notes: +/// - [ConsumerStatefulWidget] allows local loading/error state, [mounted] +/// guards on async continuations, and pull-to-refresh without lifting +/// state into a global Riverpod notifier. +/// - Error handling is fully delegated to top-level helpers in +/// `error_mappers.dart` — no `dio` import in this file (DIP). +/// - Optimistic updates mirror the pattern in [MediaGridScreen.toggleFavorite]: +/// flip immediately, reconcile/revert after the API call settles. +class PodcastEpisodesScreen extends ConsumerStatefulWidget { + /// The numeric identifier of the podcast set whose episodes will be listed. + final int setId; + + /// Optional human-readable name of the podcast feed shown in the app bar. + /// + /// Pass this when navigating from [PodcastListScreen] so the app bar title + /// appears immediately without an extra API call. + final String? setName; + + const PodcastEpisodesScreen({ + super.key, + required this.setId, + this.setName, + }); + + @override + ConsumerState<PodcastEpisodesScreen> createState() => + _PodcastEpisodesScreenState(); +} + +class _PodcastEpisodesScreenState + extends ConsumerState<PodcastEpisodesScreen> { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List<PodcastEpisode>? _episodes; + + // 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 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 episodes for [widget.setId] and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Errors are mapped by + /// [episodeListErrorMessage] 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.listEpisodes(widget.setId); + if (!mounted) return; + setState(() { + _episodes = items; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = episodeListErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Played/unplayed toggle + // --------------------------------------------------------------------------- + + /// Optimistically flips the [isCompleted] flag on the episode at [index], + /// calls [toggleEpisodeComplete] on the server, then reverts on error. + /// + /// Guard: if [_episodes] is null or [index] is out of range the call is a + /// no-op. The [mounted] check after the await prevents setState calls on a + /// disposed widget. + Future<void> _toggleCompleteAt(int index) async { + final items = _episodes; + if (items == null || index < 0 || index >= items.length) return; + + final original = items[index]; + // Flip the played state optimistically so the icon updates without lag. + final optimistic = + _buildEpisodeWithCompleted(original, !original.isCompleted); + + setState(() { + _episodes = List<PodcastEpisode>.from(items)..[index] = optimistic; + }); + + try { + final client = ref.read(apiClientProvider); + await client.toggleEpisodeComplete(original.id); + // toggleEpisodeComplete returns 204 with no body; the optimistic state + // is already correct — no reconciliation needed. + } catch (e) { + if (!mounted) return; + // Revert the optimistic update so the UI reflects actual server state. + setState(() { + final current = _episodes; + if (current != null && index < current.length) { + _episodes = List<PodcastEpisode>.from(current)..[index] = original; + } + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(episodeToggleErrorMessage(e))), + ); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(context), + ); + } + + /// Builds the app bar, showing [widget.setName] when available. + AppBar _buildAppBar() { + return AppBar( + title: Text(widget.setName ?? 'Episodes'), + ); + } + + /// Delegates to the appropriate state widget: + /// - Full-screen spinner (first load, before any data arrives). + /// - Error view with a retry button. + /// - Empty-state message when [listEpisodes] returns an empty list. + /// - Scrollable list of episode rows once data is available. + Widget _buildBody(BuildContext context) { + // Show a full-screen spinner only on the very first load (no data yet). + if (_isLoading && _episodes == null) { + return const Center( + key: Key('episodes_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. + return RefreshIndicator( + onRefresh: _load, + child: _episodes == null || _episodes!.isEmpty + ? const _EmptyView() + : _EpisodeList( + episodes: _episodes!, + onToggleComplete: _toggleCompleteAt, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable list of episode rows. +/// +/// Extracted into its own stateless widget so [_PodcastEpisodesScreenState] +/// stays concise and the list layout is independently testable. +class _EpisodeList extends StatelessWidget { + const _EpisodeList({ + required this.episodes, + required this.onToggleComplete, + }); + + final List<PodcastEpisode> episodes; + + /// Called with the index of the episode whose played state was tapped. + /// + /// Using an index (rather than the episode itself) lets the state class + /// update the correct position in its list without a linear search. + final void Function(int index) onToggleComplete; + + @override + Widget build(BuildContext context) { + return ListView.separated( + key: const Key('episodes_list'), + itemCount: episodes.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) => _EpisodeRow( + episode: episodes[index], + onToggleComplete: () => onToggleComplete(index), + ), + ); + } +} + +/// List row for a single [PodcastEpisode]. +/// +/// Shows: +/// - Episode title (dimmed when [PodcastEpisode.isCompleted] is true). +/// - Publication date and formatted duration. +/// - A linear progress bar below the title reflecting playback position +/// (visible only when the episode has been partially played). +/// - A checkmark toggle icon on the trailing edge reflecting [isCompleted]. +/// +/// Tapping the checkmark fires [onToggleComplete]; tapping the row body is +/// currently a no-op (episode playback will be wired in a future iteration). +class _EpisodeRow extends StatelessWidget { + const _EpisodeRow({ + required this.episode, + required this.onToggleComplete, + }); + + final PodcastEpisode episode; + + /// Called when the user taps the played/unplayed icon. + /// + /// The parent state performs the optimistic update and API call; this + /// widget is purely presentational (Single Responsibility / DIP). + final VoidCallback onToggleComplete; + + @override + Widget build(BuildContext context) { + return Padding( + key: Key('episode_row_${episode.id}'), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode info fills the available width. + Expanded(child: _EpisodeInfo(episode: episode)), + const SizedBox(width: 8), + // Checkmark toggle anchored to the trailing edge. + _PlayedToggle( + episodeId: episode.id, + isCompleted: episode.isCompleted, + onTap: onToggleComplete, + ), + ], + ), + ); + } +} + +/// Displays the text content of an episode row: title, meta line, and +/// optional progress bar. +/// +/// Extracted from [_EpisodeRow] to keep each widget under ~30 lines and to +/// isolate the progress-bar logic (Single Responsibility). +class _EpisodeInfo extends StatelessWidget { + const _EpisodeInfo({required this.episode}); + + final PodcastEpisode episode; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // Dim the title when the episode has been fully played so unplayed + // episodes stand out visually. + final titleColor = episode.isCompleted + ? theme.colorScheme.onSurface.withAlpha(128) + : theme.colorScheme.onSurface; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode title, dimmed when completed. + Text( + episode.title, + key: Key('episode_title_${episode.id}'), + style: theme.textTheme.bodyMedium?.copyWith(color: titleColor), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // Meta line: formatted duration and optional publish date. + _MetaLine( + durationSeconds: episode.durationSeconds, + publishedAt: episode.publishedAt, + ), + // Progress bar shown only when partially played (position > 0 and not + // fully completed) so it does not clutter completed or never-started + // episodes. + if (_shouldShowProgress) ...[ + const SizedBox(height: 6), + _PlaybackProgressBar( + positionSeconds: episode.positionSeconds, + durationSeconds: episode.durationSeconds ?? 0, + ), + ], + ], + ); + } + + /// True when the episode has a saved playback position but has not been + /// marked as fully completed — i.e. the user is partway through. + bool get _shouldShowProgress => + !episode.isCompleted && + episode.positionSeconds > 0 && + (episode.durationSeconds ?? 0) > 0; +} + +/// Displays formatted duration and optional publish date for an episode. +/// +/// Accepts only the two primitive values it actually uses ([durationSeconds] +/// and [publishedAt]) rather than the full [PodcastEpisode]. This mirrors the +/// same pattern used by [_PlaybackProgressBar] and avoids the ISP violation of +/// depending on a wide interface for two fields (Single Responsibility, ISP). +class _MetaLine extends StatelessWidget { + const _MetaLine({required this.durationSeconds, required this.publishedAt}); + + final double? durationSeconds; + final DateTime? publishedAt; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); + + final parts = <String>[]; + if (durationSeconds != null && durationSeconds! > 0) { + parts.add(formatDuration(durationSeconds!)); + } + if (publishedAt != null) { + parts.add(_formatDate(publishedAt!)); + } + + return Text( + parts.join(' · '), + style: textStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + /// Formats [date] as `MMM d, yyyy` (e.g. "Jan 5, 2024"). + /// + /// Uses pure Dart arithmetic so there is no dependency on the `intl` package. + static String _formatDate(DateTime date) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; + } +} + +/// Thin linear progress bar showing playback position relative to duration. +/// +/// The bar is only rendered when the caller has already verified that both +/// [positionSeconds] and [durationSeconds] are positive, so the fraction is +/// always in [0, 1]. +/// +/// Extracted from [_EpisodeInfo] so it is independently testable and keeps the +/// parent under ~30 lines (Single Responsibility). +class _PlaybackProgressBar extends StatelessWidget { + const _PlaybackProgressBar({ + required this.positionSeconds, + required this.durationSeconds, + }); + + final double positionSeconds; + final double durationSeconds; + + @override + Widget build(BuildContext context) { + // Clamp to [0, 1] to guard against server-side inconsistencies (e.g. + // position slightly beyond duration due to encoding length mismatch). + final fraction = (positionSeconds / durationSeconds).clamp(0.0, 1.0); + + return LinearProgressIndicator( + key: const Key('episode_progress_bar'), + value: fraction, + minHeight: 3, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ); + } +} + +/// Icon button that reflects the played/unplayed state of an episode. +/// +/// Renders a filled check-circle icon when [isCompleted] is true and an +/// outlined one otherwise. Uses a [GestureDetector] with +/// [HitTestBehavior.opaque] to consume taps without propagating to parent +/// [InkWell] widgets (mirrors [_FavoriteIconButton] in media_grid_screen.dart). +/// +/// Extracted as a separate widget so it is independently testable and to keep +/// [_EpisodeRow.build] under 30 lines (Single Responsibility). +class _PlayedToggle extends StatelessWidget { + const _PlayedToggle({ + required this.episodeId, + required this.isCompleted, + required this.onTap, + }); + + final int episodeId; + final bool isCompleted; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: Key('episode_played_toggle_$episodeId'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + isCompleted ? Icons.check_circle : Icons.check_circle_outline, + size: 24, + color: isCompleted + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} + +/// Full-screen empty-state view, shown when [listEpisodes] returns an empty +/// list. +/// +/// Wrapped in a [ListView] with [AlwaysScrollableScrollPhysics] so the +/// [RefreshIndicator] parent can still trigger a pull-to-refresh gesture even +/// when there is no scrollable 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.podcasts_outlined, + size: 72, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No episodes yet', + key: const Key('episodes_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 [listEpisodes] throws (network error, server error, etc.). +/// The [message] comes from [episodeListErrorMessage], 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('episodes_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('episodes_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/screens/podcast_list_screen.dart b/player-android/lib/screens/podcast_list_screen.dart index 1fe88dd..ee64bda 100644 --- a/player-android/lib/screens/podcast_list_screen.dart +++ b/player-android/lib/screens/podcast_list_screen.dart @@ -218,10 +218,11 @@ class _PodcastTile extends StatelessWidget { Icons.mic_outlined, color: Theme.of(context).colorScheme.primary, ), - // Navigate to the media-grid screen for this podcast's episodes. + // Navigate to the dedicated podcast episodes screen. + // The set name is forwarded as a query parameter so the episodes + // screen app bar shows it immediately without an extra API call. onTap: () => context.go( - AppRoutes.mediaGridPath(podcast.id), - extra: podcast.name, + AppRoutes.podcastEpisodesPath(podcast.id, setName: podcast.name), ), ); } diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index 3e54ce7..b0d3511 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -258,3 +258,39 @@ String folderErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.listEpisodes] to a UI string. +/// +/// Adds a 404-specific message (podcast set not found) on top of the generic +/// connection-error fallback so [PodcastEpisodesScreen] can surface actionable +/// guidance. Kept as a separate top-level function (Open-Closed, DRY) so it +/// can evolve independently of the other mappers. +String episodeListErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Podcast not found. It may have been removed.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + +/// Maps any thrown object from [PlayerApiClient.toggleEpisodeComplete] to a +/// UI string. +/// +/// The toggle is a best-effort action: 404 means the episode no longer exists, +/// 403 means the user lacks permission. All other errors fall back to a +/// generic connectivity message. Kept as a separate top-level function +/// (Open-Closed, DRY) so it can evolve independently. +String episodeToggleErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Episode not found. It may have been removed.'; + } + if (error.response?.statusCode == 403) { + return 'You do not have permission to update this episode.'; + } + return dioConnectionErrorMessage(error); + } + return 'Could not update episode. Please try again.'; +} diff --git a/player-android/test/screens/podcast_episodes_screen_test.dart b/player-android/test/screens/podcast_episodes_screen_test.dart new file mode 100644 index 0000000..be1a34d --- /dev/null +++ b/player-android/test/screens/podcast_episodes_screen_test.dart @@ -0,0 +1,638 @@ +// Widget tests for PodcastEpisodesScreen (podcast_episodes_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while listEpisodes is in flight. +// 2. Renders episode rows after a successful load. +// 3. Shows an empty-state widget when listEpisodes returns []. +// 4. Shows an error view when listEpisodes throws a DioException. +// 5. Pull-to-refresh calls listEpisodes again. +// 6. Played toggle: tapping fires onToggleComplete (optimistic update). +// 7. Progress bar is shown when positionSeconds > 0 and not completed. +// 8. Progress bar is hidden when episode is completed. +// 9. Progress bar is hidden when positionSeconds is 0. +// 10. Revert on error: played icon reverts when toggleEpisodeComplete fails. +// 11. episodeListErrorMessage and episodeToggleErrorMessage helper unit tests. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/podcast_episodes_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: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/podcast_episodes_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 [PodcastEpisodesScreen] tests. +/// +/// Only [listEpisodes] and [toggleEpisodeComplete] are implemented; all other +/// methods remain [UnimplementedError] — the screen calls only these two. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [listEpisodes] returns this list. + List<PodcastEpisode>? episodesResult; + + /// When non-null, [listEpisodes] throws this instead of returning. + Object? episodesError; + + /// Records every call to [listEpisodes] — useful for refresh tests. + int listEpisodesCallCount = 0; + + /// Completer for the current in-flight [toggleEpisodeComplete]; replaced + /// per call so each test can control one toggle at a time. + Completer<void>? _toggleCompleter; + + /// Resolves the current pending [toggleEpisodeComplete] successfully. + void completeToggle() => _toggleCompleter?.complete(); + + /// Rejects the current pending [toggleEpisodeComplete] with [error]. + void failToggle(Object error) => _toggleCompleter?.completeError(error); + + @override + Future<List<PodcastEpisode>> listEpisodes( + int podcastSetId, { + int? limit, + int? offset, + }) async { + listEpisodesCallCount++; + if (episodesError != null) throw episodesError!; + return episodesResult!; + } + + @override + Future<void> toggleEpisodeComplete(int episodeId) { + _toggleCompleter = Completer<void>(); + return _toggleCompleter!.future; + } +} + +/// [PlayerApiClient] stub that delays [listEpisodes] until [complete] is +/// called. Used to inspect the mid-flight loading state. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer<List<PodcastEpisode>>(); + + /// Resolves the pending [listEpisodes] call with [episodes]. + void complete(List<PodcastEpisode> episodes) => + _completer.complete(episodes); + + @override + Future<List<PodcastEpisode>> listEpisodes( + int podcastSetId, { + int? limit, + int? offset, + }) => + _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// An unplayed episode with no saved position. +const _kEpisode1 = PodcastEpisode( + id: 1, + feedId: 10, + guid: 'ep-1', + title: 'Introduction to Flutter', + description: 'Episode 1', + episodeUrl: 'https://example.com/ep1.mp3', + fileName: 'ep1.mp3', + isDownloaded: false, + isCompleted: false, + positionSeconds: 0, + durationSeconds: 1800.0, // 30 min +); + +/// An episode that has been partially played (halfway through). +const _kEpisodeInProgress = PodcastEpisode( + id: 2, + feedId: 10, + guid: 'ep-2', + title: 'Advanced Dart', + description: 'Episode 2', + episodeUrl: 'https://example.com/ep2.mp3', + fileName: 'ep2.mp3', + isDownloaded: false, + isCompleted: false, + positionSeconds: 900.0, // 15 min of 30 min + durationSeconds: 1800.0, +); + +/// An episode that has been fully played / marked as completed. +const _kEpisodeCompleted = PodcastEpisode( + id: 3, + feedId: 10, + guid: 'ep-3', + title: 'State Management', + description: 'Episode 3', + episodeUrl: 'https://example.com/ep3.mp3', + fileName: 'ep3.mp3', + isDownloaded: false, + isCompleted: true, + positionSeconds: 1800.0, + durationSeconds: 1800.0, +); + +// --------------------------------------------------------------------------- +// Helper: pump PodcastEpisodesScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [PodcastEpisodesScreen] (set 10, "Tech Talks") inside a +/// [ProviderScope] that overrides [apiClientProvider] and +/// [tokenStorageProvider] with fakes. +Future<void> _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: PodcastEpisodesScreen(setId: 10, setName: 'Tech Talks'), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listEpisodes is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpScreen(tester, fakeClient); + + // Pump a single frame: initState → addPostFrameCallback fires, but the + // Future has not resolved yet. + await tester.pump(); + + expect(find.byKey(const Key('episodes_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1)); + + // Resolve the fake to avoid "async work pending" warnings. + fakeClient.complete([_kEpisode1]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders episodes + // -------------------------------------------------------------------------- + + group('renders episode rows', () { + testWidgets('shows a row for each episode returned by listEpisodes', + (tester) async { + final fakeClient = _FakeApiClient() + ..episodesResult = [_kEpisode1, _kEpisodeInProgress]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.text('Introduction to Flutter'), findsOneWidget); + expect(find.text('Advanced Dart'), findsOneWidget); + }); + + testWidgets('renders the episodes list widget after a successful load', + (tester) async { + final fakeClient = _FakeApiClient()..episodesResult = [_kEpisode1]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('episodes_list')), findsOneWidget); + }); + + testWidgets('renders individual episode row keys', (tester) async { + final fakeClient = _FakeApiClient() + ..episodesResult = [_kEpisode1, _kEpisodeInProgress]; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('episode_row_1')), findsOneWidget); + expect(find.byKey(const Key('episode_row_2')), findsOneWidget); + }); + + testWidgets('shows set name in app bar when provided', (tester) async { + final fakeClient = _FakeApiClient()..episodesResult = []; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAnd |
