summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/app_routes.dart17
-rw-r--r--player-android/lib/router.dart14
-rw-r--r--player-android/lib/screens/podcast_episodes_screen.dart561
-rw-r--r--player-android/lib/screens/podcast_list_screen.dart7
-rw-r--r--player-android/lib/utils/error_mappers.dart36
-rw-r--r--player-android/test/screens/podcast_episodes_screen_test.dart638
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.pumpAndSettle();
+
+ expect(find.text('Tech Talks'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Played toggle — optimistic update
+ // --------------------------------------------------------------------------
+
+ group('played toggle — optimistic update', () {
+ testWidgets(
+ 'tapping toggle on unplayed episode immediately shows completed icon',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisode1]; // isCompleted = false
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Before tap: check_circle_outline (unplayed).
+ final toggleKey = find.byKey(const Key('episode_played_toggle_1'));
+ expect(toggleKey, findsOneWidget);
+ expect(
+ find.descendant(
+ of: toggleKey,
+ matching: find.byIcon(Icons.check_circle_outline),
+ ),
+ findsOneWidget,
+ );
+
+ // Tap the toggle — optimistic update fires immediately.
+ awa