From 5582ebd43791c41e443c53c72db9e67cdb527d22 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 17:47:08 +0300 Subject: Implement PodcastListScreen and SubscribeDialog with podcast feed management (ab) Co-Authored-By: Claude Sonnet 4.6 --- player-android/lib/api/dio_player_api_client.dart | 31 ++ player-android/lib/app_routes.dart | 4 + player-android/lib/router.dart | 7 + .../lib/screens/podcast_list_screen.dart | 377 +++++++++++++++++++ player-android/lib/screens/subscribe_dialog.dart | 282 ++++++++++++++ player-android/lib/utils/error_mappers.dart | 37 ++ .../test/screens/podcast_list_screen_test.dart | 406 +++++++++++++++++++++ .../test/screens/subscribe_dialog_test.dart | 354 ++++++++++++++++++ 8 files changed, 1498 insertions(+) create mode 100644 player-android/lib/screens/podcast_list_screen.dart create mode 100644 player-android/lib/screens/subscribe_dialog.dart create mode 100644 player-android/test/screens/podcast_list_screen_test.dart create mode 100644 player-android/test/screens/subscribe_dialog_test.dart 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 @@ -365,6 +365,37 @@ class DioPlayerApiClient extends PlayerApiClient { return Share.fromJson(response.data!); } + // --------------------------------------------------------------------------- + // 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 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 = { + 'feed_url': feedUrl, + if (setName != null && setName.isNotEmpty) 'set_name': setName, + }; + + final response = await rawDio.post>( + '$_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'; @@ -131,6 +132,12 @@ final routerProvider = Provider((ref) { path: AppRoutes.settings, 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) { 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 createState() => _PodcastListScreenState(); +} + +class _PodcastListScreenState extends ConsumerState { + // Nullable: null means "not yet loaded" (loading indicator is shown). + List? _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 _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 _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 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 showSubscribeDialog( + BuildContext context, { + required PlayerApiClient client, +}) { + return showDialog( + 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 _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 _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 _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 readToken() async => 'test-token'; + + @override + Future writeToken(String token) async {} + + @override + Future 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? 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> listSets() async { + listSetsCallCount++; + if (setsError != null) throw setsError!; + return setsResult!; + } + + @override + Future 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>(); + + /// Resolves the pending [listSets] call with [sets]. + void complete(List sets) => _completer.complete(sets); + + @override + Future> 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 _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() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listSets is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpPodcastListScreen(tester, fakeClient); + + // Pump a single frame: initState → addPostFrameCallback fires, but the + // Future has not resolved yet. + await tester.pump(); + + expect(find.byKey(const Key('podcasts_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsAtLeastNWidgets(1)); + + // Resolve the fake to avoid "async work pending" warnings. + fakeClient.complete([_kTechPodcast]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders podcasts + // -------------------------------------------------------------------------- + + group('renders podcast tiles', () { + testWidgets('shows a tile for each podcast set returned by listSets', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsResult = [_kMovies, _kTechPodcast, _kNewsPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Both podcast names must be visible; the non-podcast must not appear. + expect(find.text('Tech Talks'), findsOneWidget); + expect(find.text('Daily News'), findsOneWidget); + expect(find.text('Movies'), findsNothing); + }); + + testWidgets('renders the podcasts list after a successful load', + (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kTechPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_list')), findsOneWidget); + }); + + testWidgets('renders individual podcast tile keys', (tester) async { + final fakeClient = _FakeApiClient() + ..setsResult = [_kTechPodcast, _kNewsPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcast_tile_2')), findsOneWidget); + expect(find.byKey(const Key('podcast_tile_3')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when no podcast sets exist', + (tester) async { + // Only non-podcast sets — all filtered out. + final fakeClient = _FakeApiClient()..setsResult = [_kMovies]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_empty')), findsOneWidget); + expect(find.byKey(const Key('podcasts_list')), findsNothing); + expect(find.byKey(const Key('podcasts_loading')), findsNothing); + }); + + testWidgets('shows empty-state widget when listSets returns []', + (tester) async { + final fakeClient = _FakeApiClient()..setsResult = []; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_empty')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listSets throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_error')), findsOneWidget); + expect(find.byKey(const Key('podcasts_list')), findsNothing); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('shows retry button on error and retry calls listSets again', + (tester) async { + final fakeClient = _FakeApiClient() + ..setsError = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_retry')), findsOneWidget); + + // Fix the error before tapping retry so the second call succeeds. + fakeClient + ..setsError = null + ..setsResult = [_kTechPodcast]; + + await tester.tap(find.byKey(const Key('podcasts_retry'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcasts_list')), findsOneWidget); + // listSets was called twice: once on init, once on retry. + expect(fakeClient.listSetsCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // Pull-to-refresh + // -------------------------------------------------------------------------- + + group('pull-to-refresh', () { + testWidgets('pull-to-refresh calls listSets a second time', (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kTechPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(fakeClient.listSetsCallCount, equals(1)); + + // Simulate pull-to-refresh by dragging down on the list. + await tester.drag( + find.byKey(const Key('podcasts_list')), + const Offset(0, 300), + ); + await tester.pumpAndSettle(); + + expect(fakeClient.listSetsCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // FAB / Subscribe dialog + // -------------------------------------------------------------------------- + + group('subscribe FAB', () { + testWidgets('FAB is present on the podcast list screen', (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kTechPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('podcast_subscribe_fab')), findsOneWidget); + }); + + testWidgets('tapping FAB opens the subscribe dialog', (tester) async { + final fakeClient = _FakeApiClient()..setsResult = [_kTechPodcast]; + + await _pumpPodcastListScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('podcast_subscribe_fab'))); + await tester.pumpAndSettle(); + + // The subscribe dialog must appear. + expect(find.byKey(const Key('subscribe_dialog')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // podcastListErrorMessage helper + // -------------------------------------------------------------------------- + + group('podcastListErrorMessage', () { + test('returns connectivity message for connectionError', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + type: DioExceptionType.connectionError, + ); + expect( + podcastListErrorMessage(err), + contains('Could not reach the server'), + ); + }); + + test('returns server-error message for 500 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/sets'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/sets'), + statusCode: 500, + ), + type: DioExceptionType.badResponse, + ); + expect(podcastListErrorMessage(err), contains('500')); + }); + + test('returns generic message for unknown error type', () { + expect( + podcastListErrorMessage(Exception('boom')), + contains('Unexpected error'), + ); + }); + }); +} diff --git a/player-android/test/screens/subscribe_dialog_test.dart b/player-android/test/screens/subscribe_dialog_test.dart new file mode 100644 index 0000000..8b36417 --- /dev/null +++ b/player-android/test/screens/subscribe_dialog_test.dart @@ -0,0 +1,354 @@ +// Widget tests for SubscribeDialog (subscribe_dialog.dart). +// +// Tests cover: +// 1. Dialog renders the feed URL and set-name fields. +// 2. Submit with empty URL shows a validation error. +// 3. Successful subscribe call closes the dialog and shows a SnackBar. +// 4. API error is displayed inline inside the dialog. +// 5. Cancel closes the dialog without calling the API. +// 6. podcastErrorMessage helper unit tests. +// +// No Dio import at test level: [_FakeApiClient] overrides only the relevant +// [PlayerApiClient] methods, keeping tests hermetic (DIP/SRP). +// +// Run with: flutter test test/screens/subscribe_dialog_test.dart + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/screens/subscribe_dialog.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// Controllable [PlayerApiClient] stub for [SubscribeDialog] tests. +/// +/// Only [subscribePodcast] needs to be overridden; all other methods remain +/// [UnimplementedError] because the dialog never calls them (SRP). +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() + : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + /// When non-null, [subscribePodcast] throws this instead of returning. + Object? subscribeError; + + /// How many times [subscribePodcast] was called. + int subscribeCallCount = 0; + + /// Last captured [feedUrl] argument. + String? capturedFeedUrl; + + /// Last captured [setName] argument. + String? capturedSetName; + + @override + Future subscribePodcast({ + required String feedUrl, + String? setName, + }) async { + subscribeCallCount++; + capturedFeedUrl = feedUrl; + capturedSetName = setName; + + if (subscribeError != null) throw subscribeError!; + + return PodcastFeed( + id: 1, + setId: 10, + feedUrl: feedUrl, + title: setName ?? 'Test Podcast', + description: '', + imageUrl: '', + lastETag: '', + checkIntervalMinutes: 60, + autoDownload: false, + consecutiveFailures: 0, + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Pumps [showSubscribeDialog] inside a bare [Scaffold]. +/// +/// The dialog is opened immediately after pump so tests can inspect and +/// interact with it directly without going through a parent screen. +/// Returns the [_FakeApiClient] for assertion. +Future<_FakeApiClient> _pumpDialog( + WidgetTester tester, { + Object? subscribeError, +}) async { + final fakeClient = _FakeApiClient()..subscribeError = subscribeError; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + // Open the dialog immediately after the first frame is built. + WidgetsBinding.instance.addPostFrameCallback((_) { + showSubscribeDialog(context, client: fakeClient); + }); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + // First pump: renders the Scaffold. + // Second pump (settle): executes addPostFrameCallback and renders dialog. + await tester.pumpAndSettle(); + + return fakeClient; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // --------------------------------------------------------------------------- + // Dialog rendering + // --------------------------------------------------------------------------- + + group('renders dialog fields', () { + testWidgets('shows feed URL field, set-name field, and action buttons', + (tester) async { + await _pumpDialog(tester); + + expect(find.byKey(const Key('subscribe_dialog')), findsOneWidget); + expect(find.byKey(const Key('subscribe_feed_url')), findsOneWidget); + expect(find.byKey(const Key('subscribe_set_name')), findsOneWidget); + expect(find.byKey(const Key('subscribe_submit')), findsOneWidget); + expect(find.byKey(const Key('subscribe_cancel')), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Input validation + // --------------------------------------------------------------------------- + + group('input validation', () { + testWidgets('shows error when feed URL is empty on submit', (tester) async { + final fakeClient = await _pumpDialog(tester); + + // Tap submit without entering any URL. + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pumpAndSettle(); + + // Dialog stays open; API was NOT called. + expect(find.byKey(const Key('subscribe_dialog')), findsOneWidget); + expect(fakeClient.subscribeCallCount, equals(0)); + expect(find.byKey(const Key('subscribe_error')), findsOneWidget); + expect(find.textContaining('Feed URL is required'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Successful subscribe + // --------------------------------------------------------------------------- + + group('successful subscribe', () { + testWidgets('closes dialog and shows SnackBar on success', (tester) async { + await _pumpDialog(tester); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + await tester.tap(find.byKey(const Key('subscribe_submit'))); + // Pump frames to process the async _submit chain, dialog-close animation, + // and SnackBar entry. Avoid pumpAndSettle because the SnackBar timer + // would cause an infinite loop. 300 ms is enough to clear the dialog + // close animation (~200 ms) without waiting the full SnackBar duration. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // The dialog should have closed. + expect(find.byKey(const Key('subscribe_dialog')), findsNothing); + + // The success SnackBar must be visible. + expect(find.textContaining('Podcast subscribed'), findsOneWidget); + }); + + testWidgets('passes feedUrl and null setName to subscribePodcast when ' + 'set-name field is blank', (tester) async { + final fakeClient = await _pumpDialog(tester); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + // Leave set-name field blank. + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(fakeClient.capturedFeedUrl, equals('https://example.com/feed.rss')); + expect(fakeClient.capturedSetName, isNull); + }); + + testWidgets('passes setName to subscribePodcast when set-name field is ' + 'filled', (tester) async { + final fakeClient = await _pumpDialog(tester); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + await tester.enterText( + find.byKey(const Key('subscribe_set_name')), + 'My Podcast', + ); + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(fakeClient.capturedSetName, equals('My Podcast')); + }); + }); + + // --------------------------------------------------------------------------- + // Error display + // --------------------------------------------------------------------------- + + group('error display', () { + testWidgets('shows inline error when API throws a network error', + (tester) async { + final networkError = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + type: DioExceptionType.connectionError, + ); + + await _pumpDialog(tester, subscribeError: networkError); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pumpAndSettle(); + + // Dialog stays open (error is inline). + expect(find.byKey(const Key('subscribe_dialog')), findsOneWidget); + expect(find.byKey(const Key('subscribe_error')), findsOneWidget); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('shows 403 "not admin" message on DioException 403', + (tester) async { + final forbiddenError = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + statusCode: 403, + ), + ); + + await _pumpDialog(tester, subscribeError: forbiddenError); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('subscribe_error')), findsOneWidget); + expect(find.textContaining('administrators'), findsOneWidget); + }); + + testWidgets('shows 400 message on DioException 400', (tester) async { + final badRequestError = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + statusCode: 400, + ), + ); + + await _pumpDialog(tester, subscribeError: badRequestError); + + await tester.enterText( + find.byKey(const Key('subscribe_feed_url')), + 'https://example.com/feed.rss', + ); + await tester.tap(find.byKey(const Key('subscribe_submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('subscribe_error')), findsOneWidget); + expect(find.textContaining('Invalid feed URL'), findsOneWidget); + }); + }); + + // --------------------------------------------------------------------------- + // Cancel + // --------------------------------------------------------------------------- + + group('cancel', () { + testWidgets('tapping Cancel closes the dialog without calling the API', + (tester) async { + final fakeClient = await _pumpDialog(tester); + + await tester.tap(find.byKey(const Key('subscribe_cancel'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('subscribe_dialog')), findsNothing); + expect(fakeClient.subscribeCallCount, equals(0)); + }); + }); + + // --------------------------------------------------------------------------- + // podcastErrorMessage helper + // --------------------------------------------------------------------------- + + group('podcastErrorMessage', () { + test('returns connectivity message for connectionError', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + type: DioExceptionType.connectionError, + ); + expect(podcastErrorMessage(err), contains('Could not reach the server')); + }); + + test('returns admin-required message for 403 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + statusCode: 403, + ), + type: DioExceptionType.badResponse, + ); + expect(podcastErrorMessage(err), contains('administrators')); + }); + + test('returns invalid-feed message for 400 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/podcasts'), + statusCode: 400, + ), + type: DioExceptionType.badResponse, + ); + expect(podcastErrorMessage(err), contains('Invalid feed URL')); + }); + + test('returns generic message for unknown error type', () { + expect(podcastErrorMessage(Exception('boom')), contains('Unexpected error')); + }); + }); +} -- cgit v1.2.3