summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/subscribe_dialog.dart
diff options
context:
space:
mode:
Diffstat (limited to 'player-android/lib/screens/subscribe_dialog.dart')
-rw-r--r--player-android/lib/screens/subscribe_dialog.dart282
1 files changed, 282 insertions, 0 deletions
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),
+ );
+ }
+}