diff options
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 31 | ||||
| -rw-r--r-- | player-android/lib/app_routes.dart | 4 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/screens/podcast_list_screen.dart | 377 | ||||
| -rw-r--r-- | player-android/lib/screens/subscribe_dialog.dart | 282 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 37 | ||||
| -rw-r--r-- | player-android/test/screens/podcast_list_screen_test.dart | 406 | ||||
| -rw-r--r-- | player-android/test/screens/subscribe_dialog_test.dart | 354 |
8 files changed, 1498 insertions, 0 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index aee73f8..4443ba3 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -366,6 +366,37 @@ class DioPlayerApiClient extends PlayerApiClient { } // --------------------------------------------------------------------------- + // Podcasts + // --------------------------------------------------------------------------- + + /// Subscribes to a new podcast feed and returns the created [PodcastFeed]. + /// + /// POST /api/v1/podcasts + /// Requires admin privileges (the server returns 403 for non-admin users). + /// + /// [feedUrl] is the URL of the RSS/Atom feed to subscribe to. + /// [setName] is an optional human-readable name for the podcast set; + /// when omitted the server derives it from the feed's own title element. + @override + Future<PodcastFeed> subscribePodcast({ + required String feedUrl, + String? setName, + }) async { + // Omit set_name from the request body when not provided so the server falls + // back to the feed's title rather than receiving an explicit null. + final body = <String, dynamic>{ + 'feed_url': feedUrl, + if (setName != null && setName.isNotEmpty) 'set_name': setName, + }; + + final response = await rawDio.post<Map<String, dynamic>>( + '$_kApiV1/podcasts', + data: body, + ); + return PodcastFeed.fromJson(response.data!); + } + + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index d64c325..bc2fda1 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -25,6 +25,10 @@ abstract final class AppRoutes { /// The ':mediaId' segment identifies the media item to play. static const audioPlayer = '/audio/:mediaId'; + /// Route that lists all podcast feeds (sets where isPodcast is true). + /// Opens [PodcastListScreen] and supports the SubscribeDialog FAB. + static const podcasts = '/podcasts'; + /// Returns the concrete path for a media-detail page given a numeric [id]. static String mediaDetailPath(int id) => '/media/$id'; diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index bd45a60..2a44c16 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -12,6 +12,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_list_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; import 'screens/video_player_screen.dart'; @@ -132,6 +133,12 @@ final routerProvider = Provider<GoRouter>((ref) { builder: (context, state) => const SettingsScreen(), ), GoRoute( + // Podcast list screen — shows all sets with isPodcast == true. + // A FAB inside the screen opens the SubscribeDialog. + path: AppRoutes.podcasts, + builder: (context, state) => const PodcastListScreen(), + ), + GoRoute( path: AppRoutes.videoPlayer, builder: (context, state) { // ':mediaId' is guaranteed present by the route pattern. diff --git a/player-android/lib/screens/podcast_list_screen.dart b/player-android/lib/screens/podcast_list_screen.dart new file mode 100644 index 0000000..1fe88dd --- /dev/null +++ b/player-android/lib/screens/podcast_list_screen.dart @@ -0,0 +1,377 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_routes.dart'; +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; +import 'subscribe_dialog.dart'; + +/// Podcast list screen: displays only sets where [MediaSet.isPodcast] is true. +/// +/// Each list tile shows the feed's cover thumbnail, feed name, and a +/// microphone icon indicating it is a podcast feed. Tapping a tile +/// navigates to [MediaGridScreen] for that set's episodes. A FAB opens the +/// [showSubscribeDialog] to add a new podcast feed. +/// +/// 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. +/// - The screen calls [listSets] and filters client-side (no dedicated +/// podcast-list endpoint exists for MediaSet objects). This avoids a +/// second network round-trip and keeps the screen free of duplicated +/// loading logic. +/// - Error handling is fully delegated to [podcastListErrorMessage] in +/// `error_mappers.dart` — no `dio` import in this file (DIP). +class PodcastListScreen extends ConsumerStatefulWidget { + const PodcastListScreen({super.key}); + + @override + ConsumerState<PodcastListScreen> createState() => _PodcastListScreenState(); +} + +class _PodcastListScreenState extends ConsumerState<PodcastListScreen> { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List<MediaSet>? _podcasts; + + // Non-null when the last load attempt failed. + String? _error; + + // True while the initial or refresh load is in flight. + bool _isLoading = false; + + @override + void initState() { + super.initState(); + // Defer the first load until after the first frame so [ref] is fully bound + // and any provider overrides in the test environment are applied. + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches all sets, filters to podcast sets, and updates local state. + /// + /// Called on first mount and on pull-to-refresh. Filtering is done + /// client-side immediately after the [listSets] response so the screen never + /// shows non-podcast sets. Errors are mapped by the top-level + /// [podcastListErrorMessage] helper 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 allSets = await client.listSets(); + if (!mounted) return; + setState(() { + // Filter to podcast sets only; the home screen shows all sets. + _podcasts = allSets.where((s) => s.isPodcast).toList(); + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = podcastListErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Subscribe action + // --------------------------------------------------------------------------- + + /// Opens [showSubscribeDialog] and reloads the list on success. + /// + /// Captures [BuildContext]-dependent references before the await so that + /// post-await accesses are lint-clean (use_build_context_synchronously). + Future<void> _openSubscribeDialog() async { + final client = ref.read(apiClientProvider); + // Capture context-dependent values before the async gap. + final result = await showSubscribeDialog(context, client: client); + + // Guard: screen may have been disposed while the dialog was open. + if (!mounted) return; + + // A non-null result means the user successfully subscribed; reload the list + // so the new podcast feed appears without a manual pull-to-refresh. + if (result != null) { + await _load(); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(context), + floatingActionButton: FloatingActionButton( + key: const Key('podcast_subscribe_fab'), + tooltip: 'Subscribe to podcast', + onPressed: _openSubscribeDialog, + child: const Icon(Icons.add), + ), + ); + } + + /// Builds the app bar with the "Podcasts" title. + AppBar _buildAppBar() { + return AppBar( + title: const Text('Podcasts'), + ); + } + + /// 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 no podcast sets exist. + /// - Scrollable list of podcast feed tiles once data is available. + Widget _buildBody(BuildContext context) { + // Show a full-screen spinner only on the very first load (no data yet). + if (_isLoading && _podcasts == null) { + return const Center( + key: Key('podcasts_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: _podcasts == null || _podcasts!.isEmpty + ? const _EmptyView() + : _PodcastList(podcasts: _podcasts!), + ); + } +} + +// --------------------------------------------------------------------------- +// Sub-widgets +// --------------------------------------------------------------------------- + +/// Scrollable list of podcast feed tiles. +/// +/// Extracted into its own stateless widget so [_PodcastListScreenState] stays +/// concise and the list layout is independently testable. +class _PodcastList extends StatelessWidget { + const _PodcastList({required this.podcasts}); + + final List<MediaSet> podcasts; + + @override + Widget build(BuildContext context) { + return ListView.builder( + key: const Key('podcasts_list'), + itemCount: podcasts.length, + itemBuilder: (context, index) => _PodcastTile(podcast: podcasts[index]), + ); + } +} + +/// List tile for a single podcast [MediaSet]. +/// +/// Shows: +/// - Cover thumbnail (or a placeholder when empty). +/// - Feed name. +/// - Microphone icon indicating it is a podcast feed. +/// +/// Tapping navigates to [MediaGridScreen] for the set's episodes via +/// [AppRoutes.mediaGridPath]. The set name is forwarded as a route extra so +/// the media-grid app bar shows it immediately without a second API call. +class _PodcastTile extends StatelessWidget { + const _PodcastTile({required this.podcast}); + + final MediaSet podcast; + + @override + Widget build(BuildContext context) { + return ListTile( + key: Key('podcast_tile_${podcast.id}'), + leading: _PodcastCover(podcast: podcast), + title: Text( + podcast.name, + key: Key('podcast_name_${podcast.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // Microphone icon indicates podcast feed type. + trailing: Icon( + Icons.mic_outlined, + color: Theme.of(context).colorScheme.primary, + ), + // Navigate to the media-grid screen for this podcast's episodes. + onTap: () => context.go( + AppRoutes.mediaGridPath(podcast.id), + extra: podcast.name, + ), + ); + } +} + +/// Square cover thumbnail for a podcast feed tile. +/// +/// Uses [CachedNetworkImage] to avoid re-downloading on rebuilds and to +/// provide placeholder/error fallback states. Falls back to a grey +/// container with a microphone icon when [MediaSet.coverThumbnailPath] is +/// empty or when the network request fails. +class _PodcastCover extends StatelessWidget { + const _PodcastCover({required this.podcast}); + + final MediaSet podcast; + + @override + Widget build(BuildContext context) { + const size = 56.0; + + if (podcast.coverThumbnailPath.isEmpty) { + return _placeholderWidget(context, size); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: podcast.coverThumbnailPath, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, __) => _loadingWidget(size), + errorWidget: (_, __, ___) => _placeholderWidget(context, size), + ), + ); + } + + // Static helpers: neither uses [this], so they are class-scoped utilities. + static Widget _loadingWidget(double size) => SizedBox( + width: size, + height: size, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + + static Widget _placeholderWidget(BuildContext context, double size) => + Container( + width: size, + height: size, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.mic_outlined, + size: 28, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); +} + +/// Full-screen empty-state view, shown when no podcast sets are found. +/// +/// 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 podcasts yet', + key: const Key('podcasts_empty'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tap + to subscribe to a feed.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ], + ); + } +} + +/// Full-screen error view with a retry button. +/// +/// Shown when [listSets] throws (network error, server error, etc.). +/// The [message] comes from [podcastListErrorMessage], 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('podcasts_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('podcasts_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Top-level error-mapping helpers +// --------------------------------------------------------------------------- +// +// [podcastListErrorMessage] is defined in ../utils/error_mappers.dart, +// keeping the screen layer free of the package:dio/dio.dart dependency (DIP fix). diff --git a/player-android/lib/screens/subscribe_dialog.dart b/player-android/lib/screens/subscribe_dialog.dart new file mode 100644 index 0000000..c0c32fa --- /dev/null +++ b/player-android/lib/screens/subscribe_dialog.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; + +import '../api/player_api_client.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// showSubscribeDialog — public entry point +// --------------------------------------------------------------------------- + +/// Opens the [_SubscribeDialog] as a modal dialog. +/// +/// Returns the subscribed feed's set name on success, or `null` if the user +/// cancelled. Separating the entry-point function from the widget (Single +/// Responsibility) means call sites never construct the dialog class directly — +/// they call this function and react to the returned value. +/// +/// [client] must be an authenticated [PlayerApiClient]; no Dio import is +/// needed at the call site (Dependency Inversion Principle). +Future<String?> showSubscribeDialog( + BuildContext context, { + required PlayerApiClient client, +}) { + return showDialog<String>( + context: context, + barrierDismissible: true, + builder: (_) => _SubscribeDialog(client: client), + ); +} + +// --------------------------------------------------------------------------- +// _SubscribeDialog +// --------------------------------------------------------------------------- + +/// Modal dialog that collects a feed URL and optional set name, then calls +/// [PlayerApiClient.subscribePodcast] on submit. +/// +/// Design notes: +/// - [StatefulWidget] (not [ConsumerStatefulWidget]) because the dialog +/// only needs the injected [client]; it does not read Riverpod providers +/// directly (Dependency Inversion: the caller owns the provider read). +/// - [mounted] guards protect every async continuation. +/// - No Dio import: error mapping is delegated to [podcastErrorMessage] +/// in `error_mappers.dart` (DIP/DRY). +/// - Clipboard/SnackBar logic lives in [_handleSuccess] (Single Responsibility) +/// so the submit orchestrator stays focused on flow control only. +/// - The widget is split into focused sub-builders so the [State] class +/// stays well under 50 lines. +class _SubscribeDialog extends StatefulWidget { + const _SubscribeDialog({required this.client}); + + final PlayerApiClient client; + + @override + State<_SubscribeDialog> createState() => _SubscribeDialogState(); +} + +class _SubscribeDialogState extends State<_SubscribeDialog> { + // Controller for the required feed URL text field. + final _feedUrlController = TextEditingController(); + + // Controller for the optional set-name text field. + final _setNameController = TextEditingController(); + + // True while the subscribePodcast API call is in flight; disables buttons. + bool _isSubmitting = false; + + // Non-null when the last submit attempt failed. + String? _error; + + @override + void dispose() { + _feedUrlController.dispose(); + _setNameController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + /// Validates inputs, calls [subscribePodcast], and delegates to + /// [_handleSuccess] or displays an inline error. + /// + /// Acts as an orchestrator: validation → API call → [_handleSuccess] or + /// error display. SnackBar/Navigator logic stays in [_handleSuccess] + /// (Single Responsibility) so each method has one reason to change. + Future<void> _submit() async { + if (_isSubmitting) return; + + final feedUrl = _feedUrlController.text.trim(); + if (feedUrl.isEmpty) { + setState(() => _error = 'Feed URL is required.'); + return; + } + + setState(() { + _isSubmitting = true; + _error = null; + }); + + try { + final setName = _setNameController.text.trim(); + await widget.client.subscribePodcast( + feedUrl: feedUrl, + setName: setName.isEmpty ? null : setName, + ); + + if (!mounted) return; + await _handleSuccess(context); + } catch (e) { + if (!mounted) return; + setState(() { + _error = podcastErrorMessage(e); + _isSubmitting = false; + }); + } + } + + /// Closes the dialog and shows a success SnackBar. + /// + /// Extracted from [_submit] so the SnackBar/Navigator responsibility lives + /// in one place (Single Responsibility). Navigator and ScaffoldMessenger + /// are captured before the first `await` so they are never accessed across + /// an async gap via BuildContext (avoids use_build_context_synchronously). + Future<void> _handleSuccess(BuildContext context) async { + // Capture navigator and messenger before any async gap. + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + final feedTitle = _feedUrlController.text.trim(); + + // Close the dialog and pass back the feed URL as a success signal. + navigator.pop(feedTitle); + + // Show a success SnackBar through the outer Scaffold's messenger. + messenger.showSnackBar( + const SnackBar( + content: Text('Podcast subscribed. The feed will be fetched shortly.'), + duration: Duration(seconds: 4), + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return AlertDialog( + key: const Key('subscribe_dialog'), + title: const Text('Subscribe to Podcast'), + content: _buildContent(context), + actions: _buildActions(context), + ); + } + + /// Dialog body: feed URL field, set-name field, and optional error message. + Widget _buildContent(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FeedUrlField(controller: _feedUrlController), + const SizedBox(height: 16), + _SetNameField(controller: _setNameController), + if (_error != null) ...[ + const SizedBox(height: 12), + _ErrorText(message: _error!), + ], + ], + ); + } + + /// Cancel and Subscribe action buttons. + /// + /// Both are disabled while [_isSubmitting] is true to prevent double-submit. + List<Widget> _buildActions(BuildContext context) { + return [ + TextButton( + key: const Key('subscribe_cancel'), + onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('subscribe_submit'), + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Subscribe'), + ), + ]; + } +} + +// --------------------------------------------------------------------------- +// _FeedUrlField +// --------------------------------------------------------------------------- + +/// Required text field for the podcast feed URL. +/// +/// Extracted as a stateless widget (Single Responsibility) so +/// [_SubscribeDialogState] stays concise and the field is independently +/// testable. +class _FeedUrlField extends StatelessWidget { + const _FeedUrlField({required this.controller}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return TextField( + key: const Key('subscribe_feed_url'), + controller: controller, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Feed URL', + hintText: 'https://example.com/feed.rss', + border: OutlineInputBorder(), + isDense: true, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _SetNameField +// --------------------------------------------------------------------------- + +/// Optional text field for the podcast set name. +/// +/// When left blank the server derives the name from the feed's own title. +/// Extracted as a stateless widget (SRP) for independent testability. +class _SetNameField extends StatelessWidget { + const _SetNameField({required this.controller}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return TextField( + key: const Key('subscribe_set_name'), + controller: controller, + decoration: const InputDecoration( + labelText: 'Set name (optional)', + hintText: 'Leave blank to use the feed title', + border: OutlineInputBorder(), + isDense: true, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _ErrorText +// --------------------------------------------------------------------------- + +/// Inline error message shown when the subscribePodcast API call fails. +/// +/// Uses the error colour from [ColorScheme] for semantic consistency with +/// other error states in the app. +class _ErrorText extends StatelessWidget { + const _ErrorText({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Text( + message, + key: const Key('subscribe_error'), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ); + } +} diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index c1ca5e9..6ab9731 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -129,3 +129,40 @@ String createShareErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.listSets] (used by +/// [PodcastListScreen]) to a UI string. +/// +/// Identical delegation strategy to [setsErrorMessage]: DioExceptions are +/// mapped by [dioConnectionErrorMessage]; all other exceptions fall back to a +/// generic message. Having a separate function preserves the option to add +/// podcast-specific status-code overrides later without altering the sets +/// helper (Open-Closed Principle). +String podcastListErrorMessage(Object error) { + if (error is DioException) { + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} + +/// Maps any thrown object from [PlayerApiClient.subscribePodcast] to a UI string. +/// +/// Adds human-readable messages for the common failure modes: +/// - 400: the feed URL is malformed or the server could not parse the feed. +/// - 403: the user is not an admin (subscribe requires admin privileges). +/// - 409/500: generic server-side failure (duplicate subscription, etc.). +/// +/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the share and sets mappers. +String podcastErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 400) { + return 'Invalid feed URL or the feed could not be parsed. Check the URL and try again.'; + } + if (error.response?.statusCode == 403) { + return 'Only administrators can subscribe to podcast feeds.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} diff --git a/player-android/test/screens/podcast_list_screen_test.dart b/player-android/test/screens/podcast_list_screen_test.dart new file mode 100644 index 0000000..f148d52 --- /dev/null +++ b/player-android/test/screens/podcast_list_screen_test.dart @@ -0,0 +1,406 @@ +// Widget tests for PodcastListScreen (podcast_list_screen.dart). +// +// Tests cover: +// 1. Renders a loading indicator while the data is in flight. +// 2. Renders podcast tiles after a successful load (only podcast sets). +// 3. Navigates to MediaGridScreen on tile tap. +// 4. Shows an empty-state widget when no podcast sets exist. +// 5. Shows an error view when listSets throws. +// 6. Pull-to-refresh calls listSets again. +// 7. FAB is present and opens the SubscribeDialog. +// 8. podcastListErrorMessage 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_list_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_list_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 [PodcastListScreen] tests. +/// +/// The [listSets] behaviour is configured per-test via [setsResult] or +/// [setsError]. [subscribePodcast] is stubbed to allow the SubscribeDialog +/// FAB test to run without hitting [UnimplementedError]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [listSets] returns this list. + List<MediaSet>? setsResult; + + /// When non-null, [listSets] throws this instead of returning. + Object? setsError; + + /// Number of times [listSets] has been called; useful for refresh tests. + int listSetsCallCount = 0; + + /// Controls whether [subscribePodcast] succeeds or fails in the dialog tests. + Object? subscribeError; + + @override + Future<List<MediaSet>> listSets() async { + listSetsCallCount++; + if (setsError != null) throw setsError!; + return setsResult!; + } + + @override + Future<PodcastFeed> subscribePodcast({ + required String feedUrl, + String? setName, + }) async { + if (subscribeError != null) throw subscribeError!; + // Return a minimal stub feed so the dialog can pop successfully. + return PodcastFeed( + id: 1, + setId: 10, + feedUrl: feedUrl, + title: setName ?? 'Test Feed', + description: '', + imageUrl: '', + lastETag: '', + checkIntervalMinutes: 60, + autoDownload: false, + consecutiveFailures: 0, + ); + } +} + +/// [PlayerApiClient] stub that delays [listSets] until [complete] is called. +/// +/// Used to inspect mid-flight loading state. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer<List<MediaSet>>(); + + /// Resolves the pending [listSets] call with [sets]. + void complete(List<MediaSet> sets) => _completer.complete(sets); + + @override + Future<List<MediaSet>> listSets() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A regular (non-podcast) media set — must be filtered out by the screen. +const _kMovies = MediaSet( + id: 1, + name: 'Movies', + rootPath: 'movies', + coverThumbnailPath: '', + isPodcast: false, +); + +/// A podcast set — should appear as a tile in the podcast list. +const _kTechPodcast = MediaSet( + id: 2, + name: 'Tech Talks', + rootPath: 'podcasts/tech', + coverThumbnailPath: '', + isPodcast: true, +); + +/// A second podcast set — verifies multiple tiles are rendered. +const _kNewsPodcast = MediaSet( + id: 3, + name: 'Daily News', + rootPath: 'podcasts/news', + coverThumbnailPath: '', + isPodcast: true, +); + +// --------------------------------------------------------------------------- +// Helper: pump PodcastListScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [PodcastListScreen] inside a [ProviderScope] that overrides +/// [apiClientProvider] and [tokenStorageProvider] with fakes. +Future<void> _pumpPodcastListScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Avoid OS keychain in tests. + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + // Use the controllable fake instead of a real HTTP client. + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: PodcastListScreen(), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- |
