diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 09:45:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 09:45:52 +0300 |
| commit | aaf61a9e3c0e1439faee36c21462e0788639b7f1 (patch) | |
| tree | a6789ea0031bbc8f3ff0d5f949bc7d6505f8b52b | |
| parent | c1aa0eadf4f51dc49207db5ba1cb2e94dcfb8ca0 (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.dart | 164 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 27 | ||||
| -rw-r--r-- | player-android/test/screens/podcast_episodes_screen_test.dart | 329 |
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'), + ); + }); + }); } |
