summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 17:47:08 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 17:47:08 +0300
commit5582ebd43791c41e443c53c72db9e67cdb527d22 (patch)
tree832ac98c0fd89cc26b0f3be5b724485d884533de /player-android
parent12c967f39c35c5ffaf5714e77aae1e9b0c018733 (diff)
Implement PodcastListScreen and SubscribeDialog with podcast feed management (ab)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/api/dio_player_api_client.dart31
-rw-r--r--player-android/lib/app_routes.dart4
-rw-r--r--player-android/lib/router.dart7
-rw-r--r--player-android/lib/screens/podcast_list_screen.dart377
-rw-r--r--player-android/lib/screens/subscribe_dialog.dart282
-rw-r--r--player-android/lib/utils/error_mappers.dart37
-rw-r--r--player-android/test/screens/podcast_list_screen_test.dart406
-rw-r--r--player-android/test/screens/subscribe_dialog_test.dart354
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