diff options
Diffstat (limited to 'player-android/lib')
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 43 | ||||
| -rw-r--r-- | player-android/lib/app_routes.dart | 7 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 10 | ||||
| -rw-r--r-- | player-android/lib/screens/media_detail_screen.dart | 35 | ||||
| -rw-r--r-- | player-android/lib/screens/notes_editor_screen.dart | 461 | ||||
| -rw-r--r-- | player-android/lib/utils/error_mappers.dart | 17 |
6 files changed, 568 insertions, 5 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index 0b76b60..fdf16d1 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -474,6 +474,49 @@ class DioPlayerApiClient extends PlayerApiClient { } // --------------------------------------------------------------------------- + // Notes + // --------------------------------------------------------------------------- + + /// Returns the authenticated user's note for [mediaId], or `null` if none. + /// + /// GET /api/v1/media/{id}/notes + /// The server responds with 200 + a Note JSON object when a note exists, or + /// 204 No Content when there is no note. Dio raises no exception on 204, so + /// we detect the empty body and return null rather than trying to decode it. + @override + Future<Note?> getNote(int mediaId) async { + final response = await rawDio.get<dynamic>( + '$_kApiV1/media/$mediaId/notes', + ); + // 204 No Content — the server signals "no note exists" with an empty body. + if (response.statusCode == 204 || response.data == null) return null; + final data = response.data; + if (data is Map<String, dynamic>) return Note.fromJson(data); + return null; + } + + /// Creates or updates the authenticated user's note for [mediaId]. + /// + /// POST /api/v1/media/{id}/notes body: {"content": "<text>"} + /// Returns the saved [Note] on success. + @override + Future<Note> upsertNote(int mediaId, String content) async { + final response = await rawDio.post<Map<String, dynamic>>( + '$_kApiV1/media/$mediaId/notes', + data: {'content': content}, + ); + return Note.fromJson(response.data!); + } + + /// Deletes the authenticated user's note for [mediaId]. + /// + /// DELETE /api/v1/media/{id}/notes — returns 200 {"status": "ok"}. + @override + Future<void> deleteNote(int mediaId) async { + await rawDio.delete<void>('$_kApiV1/media/$mediaId/notes'); + } + + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index eefda4b..f7d81aa 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -32,6 +32,10 @@ abstract final class AppRoutes { /// Route that shows the Continue Watching screen (in-progress media items). static const continueWatching = '/continue'; + /// Route for the notes editor screen for a specific media item. + /// The ':mediaId' segment identifies the media item whose note is edited. + static const notes = '/notes/:mediaId'; + /// Returns the concrete path for a media-detail page given a numeric [id]. static String mediaDetailPath(int id) => '/media/$id'; @@ -51,4 +55,7 @@ abstract final class AppRoutes { /// type (including 'video' and unknown) maps to [videoPlayerPath]. static String playerPathForType(String type, String mediaId) => type == 'audio' ? audioPlayerPath(mediaId) : videoPlayerPath(mediaId); + + /// Returns the concrete path for the notes editor of a given [mediaId]. + static String notesPath(String mediaId) => '/notes/$mediaId'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 617a7f1..e9a441e 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -16,6 +16,7 @@ import 'screens/media_grid_screen.dart'; import 'screens/podcast_list_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; +import 'screens/notes_editor_screen.dart'; import 'screens/video_player_screen.dart'; // Re-export AppRoutes so existing callers that import router.dart for routes @@ -170,6 +171,15 @@ final routerProvider = Provider<GoRouter>((ref) { ); }, ), + GoRoute( + // Notes editor — shows and edits the user's personal note for a media + // item. The ':mediaId' segment is the numeric media item identifier. + path: AppRoutes.notes, + builder: (context, state) { + final mediaId = state.pathParameters['mediaId']!; + return NotesEditorScreen(mediaId: mediaId); + }, + ), ], ); }); diff --git a/player-android/lib/screens/media_detail_screen.dart b/player-android/lib/screens/media_detail_screen.dart index bee0d0e..b4d7e1f 100644 --- a/player-android/lib/screens/media_detail_screen.dart +++ b/player-android/lib/screens/media_detail_screen.dart @@ -157,6 +157,20 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { } // --------------------------------------------------------------------------- + // Notes navigation + // --------------------------------------------------------------------------- + + /// Navigates to [NotesEditorScreen] for the current media item. + /// + /// Uses [AppRoutes.notesPath] so the routing logic stays in one place + /// (Open-Closed: no URL construction scattered across the screen). + void _openNotes() { + final media = _media; + if (media == null) return; + context.go(AppRoutes.notesPath(media.id.toString())); + } + + // --------------------------------------------------------------------------- // Share // --------------------------------------------------------------------------- @@ -230,12 +244,11 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { /// Builds the app bar with title and a three-dot overflow menu. /// - /// The overflow menu currently contains a single "Share" action that opens - /// [showCreateShareDialog]. Using a [PopupMenuButton] rather than a plain - /// [IconButton] keeps the pattern open for future menu items without layout + /// The overflow menu contains "Notes" and "Share" actions. Using a + /// [PopupMenuButton] keeps the pattern open for future items without layout /// changes. The [onSelected] callback uses a [Map]-based dispatch so adding /// a new action requires only a new enum value and one map entry — no - /// if/else chain to extend (Open-Closed Principle). The Share action is + /// if/else chain to extend (Open-Closed Principle). All actions are /// disabled while media is still loading (null) to prevent calling the API /// with a stale ID. AppBar _buildAppBar() { @@ -249,12 +262,24 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { // enum value, a handler method, and one entry here — no if/else // chain to extend (Open-Closed Principle). final handlers = <_MenuAction, VoidCallback>{ + _MenuAction.notes: _openNotes, _MenuAction.share: _share, }; handlers[action]?.call(); }, itemBuilder: (_) => [ PopupMenuItem<_MenuAction>( + key: const Key('media_detail_notes_menu_item'), + // Disable the item until media has loaded so the mediaId is valid. + enabled: _media != null, + value: _MenuAction.notes, + child: const ListTile( + leading: Icon(Icons.notes_outlined), + title: Text('Notes'), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem<_MenuAction>( key: const Key('media_detail_share_menu_item'), // Disable the item until media has loaded so the mediaId is valid. enabled: _media != null, @@ -318,7 +343,7 @@ class _MediaDetailScreenState extends ConsumerState<MediaDetailScreen> { /// Using a typed enum (rather than raw strings) makes [PopupMenuButton] type /// safe and avoids stringly-typed comparisons in [onSelected] (type safety / /// Open-Closed: add new actions here without touching the menu-builder switch). -enum _MenuAction { share } +enum _MenuAction { notes, share } // --------------------------------------------------------------------------- // _MediaDetailContent diff --git a/player-android/lib/screens/notes_editor_screen.dart b/player-android/lib/screens/notes_editor_screen.dart new file mode 100644 index 0000000..88946ef --- /dev/null +++ b/player-android/lib/screens/notes_editor_screen.dart @@ -0,0 +1,461 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/models.dart'; +import '../providers/api_client_provider.dart'; +import '../utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// NotesEditorScreen +// --------------------------------------------------------------------------- + +/// Full-screen text editor for a user's personal note attached to a media item. +/// +/// Design notes: +/// - [ConsumerStatefulWidget] is used so we can manage local state for the +/// text controller, debounce timer, and loading/saving/error flags, and +/// guard async continuations with [mounted]. +/// - The note is loaded via [getNote] on first mount (deferred to +/// post-frame so [ref] is fully bound in tests). +/// - Auto-save fires 800 ms after the last keystroke via a [Timer] that is +/// cancelled and restarted on every text change (debounce pattern). The +/// timer is also cancelled in [dispose] to prevent callbacks from running +/// on a disposed widget. +/// - Manual save and clear/delete buttons live in the AppBar overflow menu, +/// using the same [Map]-dispatch / [_MenuAction] enum pattern as +/// [MediaDetailScreen] (Open-Closed Principle — adding a new action only +/// requires a new enum value and one map entry, not an if/else chain). +/// - No `dio` import — error mapping is delegated to [notesErrorMessage] in +/// error_mappers.dart (Dependency Inversion Principle). +class NotesEditorScreen extends ConsumerStatefulWidget { + /// The string form of the media ID extracted from the '/notes/:mediaId' route. + final String mediaId; + + const NotesEditorScreen({super.key, required this.mediaId}); + + @override + ConsumerState<NotesEditorScreen> createState() => _NotesEditorScreenState(); +} + +class _NotesEditorScreenState extends ConsumerState<NotesEditorScreen> { + // Controller for the multi-line text field; initialised empty and populated + // once [getNote] resolves. + late final TextEditingController _controller; + + // Nullable: null means "not yet loaded" (spinner shown in place of editor). + Note? _note; + + // Non-null when the last load or save attempt failed. + String? _error; + + // True while the initial [getNote] call is in flight. + bool _isLoading = false; + + // True while a [upsertNote] or [deleteNote] call is in flight. + // Disables the AppBar action buttons to prevent concurrent calls. + bool _isSaving = false; + + // Debounce timer: restarted on every text change; fires auto-save after + // 800 ms of inactivity. Cancelled in [dispose] to prevent callbacks from + // running on a disposed widget. + Timer? _debounce; + + // Tracks the last content successfully saved to the server so we can skip + // unnecessary upsert calls when the user pauses without actually changing + // the text. + String _savedContent = ''; + + // Auto-save debounce delay in milliseconds. + static const _kDebounceMs = 800; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + // 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()); + } + + @override + void dispose() { + // Cancel any pending debounce timer before the widget is unmounted so the + // auto-save callback never fires on a disposed widget (mounted guard would + // also catch it, but explicit cancellation is clearer and avoids the call). + _debounce?.cancel(); + _controller.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Data loading + // --------------------------------------------------------------------------- + + /// Fetches the existing note for this media item and populates the editor. + /// + /// Called on first mount. A null response (204 No Content) means no note + /// exists yet — the editor starts empty and the first save creates one. + /// Errors are mapped by the top-level [notesErrorMessage] helper so no + /// `dio` import is needed. + Future<void> _load() async { + if (!mounted) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final id = int.tryParse(widget.mediaId) ?? 0; + final client = ref.read(apiClientProvider); + final note = await client.getNote(id); + if (!mounted) return; + final content = note?.content ?? ''; + _controller.text = content; + // Initialise _savedContent so the first auto-save does not trigger an + // unnecessary upsert when the user taps into the editor without typing. + setState(() { + _note = note; + _savedContent = content; + _isLoading = false; + }); + // Listen for text changes *after* the initial content is set so the + // listener does not fire a spurious auto-save on the seed value. + _controller.addListener(_onTextChanged); + } catch (e) { + if (!mounted) return; + setState(() { + _error = notesErrorMessage(e); + _isLoading = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Auto-save (debounce) + // --------------------------------------------------------------------------- + + /// Called on every text-field change; restarts the 800 ms debounce timer. + /// + /// Cancelling the previous timer before starting a new one ensures only one + /// save fires per burst of keystrokes, not one per keystroke. + void _onTextChanged() { + _debounce?.cancel(); + _debounce = Timer( + const Duration(milliseconds: _kDebounceMs), + _autoSave, + ); + } + + /// Fires after the debounce delay; saves only when content has actually changed. + /// + /// Skips the call if the text matches what was last saved to avoid hammering + /// the server when the user pauses without typing (idempotent guard). + Future<void> _autoSave() async { + final content = _controller.text; + // Skip save if nothing has changed since the last successful save. + if (content == _savedContent) return; + await _save(content); + } + + // --------------------------------------------------------------------------- + // Save / clear + // --------------------------------------------------------------------------- + + /// Persists [content] via [upsertNote] and updates local state on success. + /// + /// [_isSaving] is set for the duration so the AppBar buttons are disabled. + /// Errors are shown as a [SnackBar] rather than replacing the editor (the user + /// should be able to keep editing even when a save temporarily fails). + Future<void> _save(String content) async { + if (_isSaving || !mounted) return; + setState(() => _isSaving = true); + + try { + final id = int.tryParse(widget.mediaId) ?? 0; + final client = ref.read(apiClientProvider); + final saved = await client.upsertNote(id, content); + if (!mounted) return; + setState(() { + _note = saved; + _savedContent = saved.content; + _isSaving = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(notesErrorMessage(e))), + ); + } + } + + /// Asks for confirmation before deleting the note. + /// + /// Shows an [AlertDialog] with Cancel / Clear actions. On confirmation it + /// calls [deleteNote], clears the editor, and resets local state. + /// The dialog is shown only when a note actually exists (non-empty content) + /// so the menu item is a no-op when the editor is already empty. + Future<void> _clear() async { + // Nothing to clear: the editor is already empty. + if (_controller.text.isEmpty && _note == null) return; + if (!mounted) return; + + final confirmed = await showDialog<bool>( + context: context, + builder: (ctx) => AlertDialog( + key: const Key('notes_clear_dialog'), + title: const Text('Clear note'), + content: const Text( + 'This will permanently delete your note. Are you sure?', + ), + actions: [ + TextButton( + key: const Key('notes_clear_cancel'), + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('notes_clear_confirm'), + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + // Cancel any pending debounce so the auto-save doesn't fire after delete. + _debounce?.cancel(); + + setState(() => _isSaving = true); + + try { + final id = int.tryParse(widget.mediaId) ?? 0; + final client = ref.read(apiClientProvider); + await client.deleteNote(id); + if (!mounted) return; + // Reset all local state: the note is gone. + _controller.text = ''; + setState(() { + _note = null; + _savedContent = ''; + _isSaving = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(notesErrorMessage(e))), + ); + } + } + + /// Manually saves the current editor content immediately (no debounce). + /// + /// Called from the AppBar overflow "Save" menu item so users can force a + /// save without waiting for the debounce delay. The debounce timer is + /// cancelled first to avoid a double-save. + Future<void> _manualSave() async { + _debounce?.cancel(); + await _save(_controller.text); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(), + body: _buildBody(context), + ); + } + + /// Builds the AppBar with title and an overflow menu for Save and Clear. + /// + /// [Map]-based dispatch keeps the [onSelected] handler closed for modification + /// (Open-Closed Principle): adding a new action requires only a new enum value + /// and one entry in [handlers], not an if/else chain to extend. + /// + /// Both actions are disabled while a save is in flight ([_isSaving]) to + /// prevent concurrent API calls. + AppBar _buildAppBar() { + return AppBar( + title: Text('Notes – ${widget.mediaId}'), + actions: [ + if (_isSaving) + // Unobtrusive progress indicator while a save is in flight. + const Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: SizedBox( + key: Key('notes_saving_indicator'), + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + PopupMenuButton<_MenuAction>( + key: const Key('notes_overflow_menu'), + // Disable the menu entirely while saving to prevent concurrent calls. + enabled: !_isSaving && !_isLoading, + onSelected: (action) { + // Map-dispatch: extend by adding enum values + entries here only. + final handlers = <_MenuAction, VoidCallback>{ + _MenuAction.save: _manualSave, + _MenuAction.clear: _clear, + }; + handlers[action]?.call(); + }, + itemBuilder: (_) => [ + const PopupMenuItem<_MenuAction>( + key: Key('notes_save_menu_item'), + value: _MenuAction.save, + child: ListTile( + leading: Icon(Icons.save_outlined), + title: Text('Save'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem<_MenuAction>( + key: Key('notes_clear_menu_item'), + value: _MenuAction.clear, + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Clear'), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ); + } + + /// Delegates to the appropriate state widget based on loading/error/data. + Widget _buildBody(BuildContext context) { + // Full-screen spinner only on the very first load (no data yet). + if (_isLoading) { + return const Center( + key: Key('notes_loading'), + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return _ErrorView( + message: _error!, + onRetry: _load, + ); + } + + // The editor is shown even when no note exists yet (empty state): the user + // can type immediately and the first auto-save will create the note. + return _NoteEditor(controller: _controller); + } +} + +// --------------------------------------------------------------------------- +// _MenuAction +// --------------------------------------------------------------------------- + +/// Enum of available overflow-menu actions in [NotesEditorScreen]. +/// +/// Typed enum keeps [PopupMenuButton] type-safe and the [onSelected] map +/// dispatch closed for modification (Open-Closed Principle). +enum _MenuAction { save, clear } + +// --------------------------------------------------------------------------- +// _NoteEditor +// --------------------------------------------------------------------------- + +/// Full-screen multi-line text editor for the note content. +/// +/// Extracted from [_NotesEditorScreenState] so the state class stays concise +/// and this widget is independently testable. The [TextEditingController] is +/// injected so the parent retains ownership and control of the text value. +class _NoteEditor extends StatelessWidget { + const _NoteEditor({required this.controller}); + + /// Injected controller so the parent [_NotesEditorScreenState] can read the + /// current text and receive change notifications. + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: TextField( + key: const Key('notes_text_field'), + controller: controller, + // Expand the text field to fill the available vertical space, making + // the full screen feel like a proper editor rather than a small input. + expands: true, + maxLines: null, + minLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + hintText: 'Write your notes here…', + border: InputBorder.none, + // Disable all visual borders — the full-screen layout is the + // container; extra decoration would be distracting. + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _ErrorView +// --------------------------------------------------------------------------- + +/// Full-screen error view with a retry button. +/// +/// Shown when [getNote] throws on initial load. [message] comes from +/// [notesErrorMessage]; [onRetry] triggers a fresh [_load] call. +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('notes_error'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + key: const Key('notes_retry'), + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart index ea20412..bb42f9b 100644 --- a/player-android/lib/utils/error_mappers.dart +++ b/player-android/lib/utils/error_mappers.dart @@ -201,3 +201,20 @@ String podcastErrorMessage(Object error) { } return 'Unexpected error. Please try again.'; } + +/// Maps any thrown object from [PlayerApiClient.getNote], [upsertNote], or +/// [deleteNote] to a human-readable UI string. +/// +/// Adds a 404-specific message (media not found) so the notes editor can +/// surface actionable guidance rather than a raw server-error code. Kept as +/// a separate top-level function (Open-Closed, DRY) so it can evolve +/// independently of the other mappers. +String notesErrorMessage(Object error) { + if (error is DioException) { + if (error.response?.statusCode == 404) { + return 'Media not found. It may have been deleted.'; + } + return dioConnectionErrorMessage(error); + } + return 'Unexpected error. Please try again.'; +} |
