summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 09:45:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 09:45:52 +0300
commitaaf61a9e3c0e1439faee36c21462e0788639b7f1 (patch)
treea6789ea0031bbc8f3ff0d5f949bc7d6505f8b52b
parentc1aa0eadf4f51dc49207db5ba1cb2e94dcfb8ca0 (diff)
Add play and download buttons to PodcastEpisodesScreen (bb)
Each episode row now shows a play button (when mediaId is non-null) that navigates to AudioPlayerScreen, or a download button (when mediaId is null) that triggers a server-side download via downloadEpisode. On success the row updates in-place to swap the download button for a play button without requiring a full page reload. Added episodeDownloadErrorMessage to error_mappers.dart and extended the test suite to cover all new paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/lib/screens/podcast_episodes_screen.dart164
-rw-r--r--player-android/lib/utils/error_mappers.dart27
-rw-r--r--player-android/test/screens/podcast_episodes_screen_test.dart329
3 files changed, 512 insertions, 8 deletions
diff --git a/player-android/lib/screens/podcast_episodes_screen.dart b/player-android/lib/screens/podcast_episodes_screen.dart
index 330412e..ef4de53 100644
--- a/player-android/lib/screens/podcast_episodes_screen.dart
+++ b/player-android/lib/screens/podcast_episodes_screen.dart
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import '../app_routes.dart';
import '../models/models.dart';
import '../providers/api_client_provider.dart';
import '../utils/duration_formatter.dart';
@@ -159,6 +161,48 @@ class _PodcastEpisodesScreenState
}
// ---------------------------------------------------------------------------
+ // Download
+ // ---------------------------------------------------------------------------
+
+ /// Triggers a server-side download of the episode at [index] and updates
+ /// the row with the returned [Media.id] so the play button becomes active.
+ ///
+ /// The download is initiated on the server; the returned [Media] object
+ /// carries the newly created [mediaId]. On success the episode row is
+ /// updated in-place so the play button appears without a full reload.
+ /// On failure the original row is preserved and a SnackBar is shown.
+ Future<void> _downloadEpisodeAt(int index) async {
+ final items = _episodes;
+ if (items == null || index < 0 || index >= items.length) return;
+
+ final original = items[index];
+ // Guard: do not re-download an episode that already has a media file.
+ if (original.mediaId != null) return;
+
+ try {
+ final client = ref.read(apiClientProvider);
+ final media = await client.downloadEpisode(original.id);
+ if (!mounted) return;
+
+ // Update the episode row with the server-assigned mediaId so the play
+ // button appears without waiting for a full page reload.
+ final updated = PodcastEpisode.fromJson(
+ original.toJson()
+ ..['media_id'] = media.id
+ ..['is_downloaded'] = true,
+ );
+ setState(() {
+ _episodes = List<PodcastEpisode>.from(items)..[index] = updated;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text(episodeDownloadErrorMessage(e))),
+ );
+ }
+ }
+
+ // ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
@@ -205,6 +249,12 @@ class _PodcastEpisodesScreenState
: _EpisodeList(
episodes: _episodes!,
onToggleComplete: _toggleCompleteAt,
+ onDownload: _downloadEpisodeAt,
+ // Navigate to AudioPlayerScreen; mediaId is guaranteed non-null
+ // here because the play button is only rendered when mediaId != null.
+ onPlay: (mediaId) => context.go(
+ AppRoutes.audioPlayerPath(mediaId.toString()),
+ ),
),
);
}
@@ -222,6 +272,8 @@ class _EpisodeList extends StatelessWidget {
const _EpisodeList({
required this.episodes,
required this.onToggleComplete,
+ required this.onDownload,
+ required this.onPlay,
});
final List<PodcastEpisode> episodes;
@@ -232,6 +284,15 @@ class _EpisodeList extends StatelessWidget {
/// update the correct position in its list without a linear search.
final void Function(int index) onToggleComplete;
+ /// Called with the index of the episode to be downloaded from the server.
+ final void Function(int index) onDownload;
+
+ /// Called with the [mediaId] of the episode to play.
+ ///
+ /// Only invoked when the episode already has a [PodcastEpisode.mediaId]
+ /// (i.e. it has been downloaded and a Media row exists on the server).
+ final void Function(int mediaId) onPlay;
+
@override
Widget build(BuildContext context) {
return ListView.separated(
@@ -241,6 +302,8 @@ class _EpisodeList extends StatelessWidget {
itemBuilder: (context, index) => _EpisodeRow(
episode: episodes[index],
onToggleComplete: () => onToggleComplete(index),
+ onDownload: () => onDownload(index),
+ onPlay: onPlay,
),
);
}
@@ -253,14 +316,17 @@ class _EpisodeList extends StatelessWidget {
/// - 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).
+/// - Action buttons on the trailing edge:
+/// * Play button (when [PodcastEpisode.mediaId] is non-null).
+/// * Download button (when [PodcastEpisode.mediaId] is null, i.e. not yet
+/// downloaded from the remote feed to the server's media library).
+/// * Checkmark toggle reflecting [PodcastEpisode.isCompleted].
class _EpisodeRow extends StatelessWidget {
const _EpisodeRow({
required this.episode,
required this.onToggleComplete,
+ required this.onDownload,
+ required this.onPlay,
});
final PodcastEpisode episode;
@@ -271,6 +337,16 @@ class _EpisodeRow extends StatelessWidget {
/// widget is purely presentational (Single Responsibility / DIP).
final VoidCallback onToggleComplete;
+ /// Called when the user taps the download icon.
+ ///
+ /// Only shown when [episode.mediaId] is null (episode not yet downloaded).
+ final VoidCallback onDownload;
+
+ /// Called with the episode's [mediaId] when the user taps the play icon.
+ ///
+ /// Only shown when [episode.mediaId] is non-null (episode is downloaded).
+ final void Function(int mediaId) onPlay;
+
@override
Widget build(BuildContext context) {
return Padding(
@@ -281,7 +357,18 @@ class _EpisodeRow extends StatelessWidget {
children: [
// Episode info fills the available width.
Expanded(child: _EpisodeInfo(episode: episode)),
- const SizedBox(width: 8),
+ const SizedBox(width: 4),
+ // Show play or download depending on whether the media file exists.
+ if (episode.mediaId != null)
+ _PlayButton(
+ episodeId: episode.id,
+ onTap: () => onPlay(episode.mediaId!),
+ )
+ else
+ _DownloadButton(
+ episodeId: episode.id,
+ onTap: onDownload,
+ ),
// Checkmark toggle anchored to the trailing edge.
_PlayedToggle(
episodeId: episode.id,
@@ -432,6 +519,73 @@ class _PlaybackProgressBar extends StatelessWidget {
}
}
+/// Icon button that opens the [AudioPlayerScreen] for a downloaded episode.
+///
+/// Shown in [_EpisodeRow] only when [PodcastEpisode.mediaId] is non-null,
+/// meaning the episode has been downloaded and a [Media] row exists.
+/// Uses [GestureDetector] with [HitTestBehavior.opaque] to consume taps
+/// without propagating to parent [InkWell] widgets (mirrors [_PlayedToggle]).
+class _PlayButton extends StatelessWidget {
+ const _PlayButton({
+ required this.episodeId,
+ required this.onTap,
+ });
+
+ final int episodeId;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ key: Key('episode_play_button_$episodeId'),
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Icon(
+ Icons.play_circle_outline,
+ size: 24,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ ),
+ );
+ }
+}
+
+/// Icon button that triggers a server-side download of an episode.
+///
+/// Shown in [_EpisodeRow] only when [PodcastEpisode.mediaId] is null —
+/// meaning the episode has not yet been fetched from the remote feed URL
+/// into the server's media library. Once downloaded, the server creates a
+/// [Media] row and [PodcastEpisode.mediaId] becomes non-null, replacing this
+/// button with [_PlayButton] in the next render cycle.
+class _DownloadButton extends StatelessWidget {
+ const _DownloadButton({
+ required this.episodeId,
+ required this.onTap,
+ });
+
+ final int episodeId;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ key: Key('episode_download_button_$episodeId'),
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Icon(
+ Icons.download_outlined,
+ size: 24,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+}
+
/// Icon button that reflects the played/unplayed state of an episode.
///
/// Renders a filled check-circle icon when [isCompleted] is true and an
diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart
index 8f5c78b..4427efa 100644
--- a/player-android/lib/utils/error_mappers.dart
+++ b/player-android/lib/utils/error_mappers.dart
@@ -320,3 +320,30 @@ String episodeToggleErrorMessage(Object error) {
}
return 'Could not update episode. Please try again.';
}
+
+/// Maps any thrown object from [PlayerApiClient.downloadEpisode] to a UI string.
+///
+/// Adds human-readable messages for the failure modes specific to triggering a
+/// server-side episode download:
+/// - 404: the episode no longer exists on the server.
+/// - 403: the user lacks the required permission.
+/// - 409: the episode has already been downloaded (concurrent request).
+///
+/// All other failures fall back to [dioConnectionErrorMessage]. Kept as a
+/// separate top-level function (Open-Closed, DRY) so it can evolve
+/// independently of the toggle and list mappers.
+String episodeDownloadErrorMessage(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 download this episode.';
+ }
+ if (error.response?.statusCode == 409) {
+ return 'Episode is already downloaded.';
+ }
+ return dioConnectionErrorMessage(error);
+ }
+ return 'Could not download 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
index be1a34d..dcf289f 100644
--- a/player-android/test/screens/podcast_episodes_screen_test.dart
+++ b/player-android/test/screens/podcast_episodes_screen_test.dart
@@ -12,6 +12,11 @@
// 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.
+// 12. Play button visible when mediaId is non-null; tapping navigates to audio player.
+// 13. Download button visible when mediaId is null; tapping calls downloadEpisode.
+// 14. Download success updates row to show play button.
+// 15. Download error shows SnackBar.
+// 16. episodeDownloadErrorMessage helper unit tests.
//
// Riverpod providers are overridden with fakes so tests run without a real
// server or OS keychain.
@@ -24,6 +29,7 @@ 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';
@@ -51,8 +57,8 @@ class _FakeTokenStorage implements TokenStorage {
/// Controllable [PlayerApiClient] stub for [PodcastEpisodesScreen] tests.
///
-/// Only [listEpisodes] and [toggleEpisodeComplete] are implemented; all other
-/// methods remain [UnimplementedError] — the screen calls only these two.
+/// Implements [listEpisodes], [toggleEpisodeComplete], and [downloadEpisode];
+/// all other methods remain [UnimplementedError] — the screen calls only these.
class _FakeApiClient extends PlayerApiClient {
_FakeApiClient() : super(dio: Dio());
@@ -75,6 +81,15 @@ class _FakeApiClient extends PlayerApiClient {
/// Rejects the current pending [toggleEpisodeComplete] with [error].
void failToggle(Object error) => _toggleCompleter?.completeError(error);
+ /// When non-null, [downloadEpisode] returns this [Media] result.
+ Media? downloadResult;
+
+ /// When non-null, [downloadEpisode] throws this error.
+ Object? downloadError;
+
+ /// Records every call to [downloadEpisode] — useful for download tests.
+ int downloadEpisodeCallCount = 0;
+
@override
Future<List<PodcastEpisode>> listEpisodes(
int podcastSetId, {
@@ -91,6 +106,13 @@ class _FakeApiClient extends PlayerApiClient {
_toggleCompleter = Completer<void>();
return _toggleCompleter!.future;
}
+
+ @override
+ Future<Media> downloadEpisode(int episodeId) async {
+ downloadEpisodeCallCount++;
+ if (downloadError != null) throw downloadError!;
+ return downloadResult!;
+ }
}
/// [PlayerApiClient] stub that delays [listEpisodes] until [complete] is
@@ -162,13 +184,61 @@ const _kEpisodeCompleted = PodcastEpisode(
durationSeconds: 1800.0,
);
+/// An episode that has been downloaded (mediaId is non-null) and is playable.
+const _kEpisodeDownloaded = PodcastEpisode(
+ id: 4,
+ feedId: 10,
+ mediaId: 99,
+ guid: 'ep-4',
+ title: 'Async Await Deep Dive',
+ description: 'Episode 4',
+ episodeUrl: 'https://example.com/ep4.mp3',
+ fileName: 'ep4.mp3',
+ isDownloaded: true,
+ isCompleted: false,
+ positionSeconds: 0,
+ durationSeconds: 2700.0,
+);
+
// ---------------------------------------------------------------------------
-// Helper: pump PodcastEpisodesScreen inside a minimal ProviderScope.
+// Stub Media used as the downloadEpisode response.
// ---------------------------------------------------------------------------
+/// Minimal [Media] stub returned by the fake [downloadEpisode] implementation.
+///
+/// Only [id] is relevant to [PodcastEpisodesScreen]; the remaining required
+/// fields are filled with zero-values so the const constructor compiles.
+const _kDownloadedMedia = Media(
+ id: 55,
+ setId: 10,
+ relPath: 'podcasts/ep1.mp3',
+ fileName: 'ep1.mp3',
+ absPath: '/media/podcasts/ep1.mp3',
+ type: 'audio',
+ duration: 1800.0,
+ codec: 'mp3',
+ resolution: '',
+ bitrate: 128,
+ fileSizeBytes: 0,
+ width: 0,
+ height: 0,
+ thumbnailPath: '',
+ playCount: 0,
+);
+
+// ---------------------------------------------------------------------------
+// Helpers: pump PodcastEpisodesScreen inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Key used by the stub audio-player destination route in navigation tests.
+const _kAudioPlayerDestKey = Key('nav_audio_player');
+
/// Pumps [PodcastEpisodesScreen] (set 10, "Tech Talks") inside a
/// [ProviderScope] that overrides [apiClientProvider] and
/// [tokenStorageProvider] with fakes.
+///
+/// Uses plain [MaterialApp] (no GoRouter) — suited for tests that do not
+/// exercise navigation (i.e. no episode rows with [mediaId] non-null).
Future<void> _pumpScreen(
WidgetTester tester,
PlayerApiClient fakeClient,
@@ -186,6 +256,51 @@ Future<void> _pumpScreen(
);
}
+/// Builds a [GoRouter] with [PodcastEpisodesScreen] at its root and a stub
+/// `/audio/:mediaId` route so navigation tests can verify the correct
+/// destination is reached when the play button is tapped.
+GoRouter _buildRouterWithAudioStub(PlayerApiClient fakeClient) {
+ return GoRouter(
+ initialLocation: '/podcasts/10/episodes',
+ routes: [
+ GoRoute(
+ path: '/podcasts/:setId/episodes',
+ builder: (context, state) {
+ final setId = int.tryParse(state.pathParameters['setId']!) ?? 0;
+ return PodcastEpisodesScreen(setId: setId, setName: 'Tech Talks');
+ },
+ ),
+ GoRoute(
+ path: '/audio/:mediaId',
+ builder: (context, state) => Scaffold(
+ body: Text(
+ 'Audio ${state.pathParameters['mediaId']}',
+ key: _kAudioPlayerDestKey,
+ ),
+ ),
+ ),
+ ],
+ );
+}
+
+/// Pumps [PodcastEpisodesScreen] inside a [GoRouter] so that navigation via
+/// `context.go('/audio/:mediaId')` resolves correctly in play-button tests.
+Future<void> _pumpScreenWithRouter(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ final router = _buildRouterWithAudioStub(fakeClient);
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()),
+ apiClientProvider.overrideWithValue(fakeClient),
+ ],
+ child: MaterialApp.router(routerConfig: router),
+ ),
+ );
+}
+
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -635,4 +750,212 @@ void main() {
);
});
});
+
+ // --------------------------------------------------------------------------
+ // Play button
+ // --------------------------------------------------------------------------
+
+ group('play button', () {
+ testWidgets(
+ 'shows play button when episode has a mediaId (is downloaded)',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisodeDownloaded]; // mediaId: 99
+
+ await _pumpScreenWithRouter(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const Key('episode_play_button_4')),
+ findsOneWidget,
+ );
+ // Download button must not appear when mediaId is set.
+ expect(
+ find.byKey(const Key('episode_download_button_4')),
+ findsNothing,
+ );
+ });
+
+ testWidgets(
+ 'tapping play button navigates to audio player screen',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisodeDownloaded]; // mediaId: 99
+
+ await _pumpScreenWithRouter(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('episode_play_button_4')));
+ await tester.pumpAndSettle();
+
+ // Navigation should have pushed the stub audio-player route.
+ expect(find.byKey(_kAudioPlayerDestKey), findsOneWidget);
+ expect(find.text('Audio 99'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Download button
+ // --------------------------------------------------------------------------
+
+ group('download button', () {
+ testWidgets(
+ 'shows download button when episode has no mediaId (not downloaded)',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisode1]; // mediaId: null
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const Key('episode_download_button_1')),
+ findsOneWidget,
+ );
+ // Play button must not appear when mediaId is null.
+ expect(
+ find.byKey(const Key('episode_play_button_1')),
+ findsNothing,
+ );
+ });
+
+ testWidgets(
+ 'tapping download button calls downloadEpisode',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisode1]
+ ..downloadResult = _kDownloadedMedia;
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('episode_download_button_1')));
+ await tester.pumpAndSettle();
+
+ expect(fakeClient.downloadEpisodeCallCount, equals(1));
+ });
+
+ testWidgets(
+ 'successful download replaces download button with play button',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisode1]
+ ..downloadResult = _kDownloadedMedia; // returns mediaId 55
+
+ await _pumpScreenWithRouter(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Before download: download button visible, play button absent.
+ expect(
+ find.byKey(const Key('episode_download_button_1')),
+ findsOneWidget,
+ );
+
+ await tester.tap(find.byKey(const Key('episode_download_button_1')));
+ await tester.pumpAndSettle();
+
+ // After successful download: play button visible, download button absent.
+ expect(find.byKey(const Key('episode_play_button_1')), findsOneWidget);
+ expect(
+ find.byKey(const Key('episode_download_button_1')),
+ findsNothing,
+ );
+ });
+
+ testWidgets(
+ 'download error shows SnackBar with error message',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..episodesResult = [_kEpisode1]
+ ..downloadError = DioException(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ type: DioExceptionType.connectionError,
+ );
+
+ await _pumpScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('episode_download_button_1')));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(SnackBar), findsOneWidget);
+ expect(find.textContaining('Could not reach'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // episodeDownloadErrorMessage helper
+ // --------------------------------------------------------------------------
+
+ group('episodeDownloadErrorMessage', () {
+ test('returns connectivity message for connectionError', () {
+ final err = DioException(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ type: DioExceptionType.connectionError,
+ );
+ expect(
+ episodeDownloadErrorMessage(err),
+ contains('Could not reach'),
+ );
+ });
+
+ test('returns not-found message for 404', () {
+ final err = DioException(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ response: Response(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ statusCode: 404,
+ ),
+ type: DioExceptionType.badResponse,
+ );
+ expect(episodeDownloadErrorMessage(err), contains('not found'));
+ });
+
+ test('returns permission message for 403', () {
+ final err = DioException(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ response: Response(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ statusCode: 403,
+ ),
+ type: DioExceptionType.badResponse,
+ );
+ expect(episodeDownloadErrorMessage(err), contains('permission'));
+ });
+
+ test('returns already-downloaded message for 409', () {
+ final err = DioException(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ response: Response(
+ requestOptions: RequestOptions(
+ path: '/api/v1/podcasts/episodes/1/download',
+ ),
+ statusCode: 409,
+ ),
+ type: DioExceptionType.badResponse,
+ );
+ expect(episodeDownloadErrorMessage(err), contains('already downloaded'));
+ });
+
+ test('returns generic message for non-Dio error', () {
+ expect(
+ episodeDownloadErrorMessage(Exception('boom')),
+ contains('Could not download episode'),
+ );
+ });
+ });
}