From 444fb60ccacf7cba02467f7d6af2efd5d44e3b3b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 22:54:27 +0300 Subject: Implement NotesEditorScreen with auto-save debounce and clear confirmation (6b) Co-Authored-By: Claude Sonnet 4.6 --- player-android/lib/api/dio_player_api_client.dart | 43 ++ player-android/lib/app_routes.dart | 7 + player-android/lib/router.dart | 10 + .../lib/screens/media_detail_screen.dart | 35 +- .../lib/screens/notes_editor_screen.dart | 461 +++++++++++++++++++ player-android/lib/utils/error_mappers.dart | 17 + .../test/screens/notes_editor_screen_test.dart | 506 +++++++++++++++++++++ 7 files changed, 1074 insertions(+), 5 deletions(-) create mode 100644 player-android/lib/screens/notes_editor_screen.dart create mode 100644 player-android/test/screens/notes_editor_screen_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 0b76b60..fdf16d1 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -473,6 +473,49 @@ class DioPlayerApiClient extends PlayerApiClient { return PodcastFeed.fromJson(response.data!); } + // --------------------------------------------------------------------------- + // 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 getNote(int mediaId) async { + final response = await rawDio.get( + '$_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) return Note.fromJson(data); + return null; + } + + /// Creates or updates the authenticated user's note for [mediaId]. + /// + /// POST /api/v1/media/{id}/notes body: {"content": ""} + /// Returns the saved [Note] on success. + @override + Future upsertNote(int mediaId, String content) async { + final response = await rawDio.post>( + '$_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 deleteNote(int mediaId) async { + await rawDio.delete('$_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((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 @@ -156,6 +156,20 @@ class _MediaDetailScreenState extends ConsumerState { return Media.fromJson(json); } + // --------------------------------------------------------------------------- + // 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 { /// 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,11 +262,23 @@ class _MediaDetailScreenState extends ConsumerState { // 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. @@ -318,7 +343,7 @@ class _MediaDetailScreenState extends ConsumerState { /// 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 createState() => _NotesEditorScreenState(); +} + +class _NotesEditorScreenState extends ConsumerState { + // 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 _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 _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 _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 _clear() async { + // Nothing to clear: the editor is already empty. + if (_controller.text.isEmpty && _note == null) return; + if (!mounted) return; + + final confirmed = await showDialog( + 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 _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.'; +} diff --git a/player-android/test/screens/notes_editor_screen_test.dart b/player-android/test/screens/notes_editor_screen_test.dart new file mode 100644 index 0000000..45e4c89 --- /dev/null +++ b/player-android/test/screens/notes_editor_screen_test.dart @@ -0,0 +1,506 @@ +// Widget tests for NotesEditorScreen (notes_editor_screen.dart). +// +// Tests cover: +// 1. Shows a loading indicator while getNote is in flight. +// 2. Loads an existing note into the editor on init. +// 3. Shows an empty editor when getNote returns null (no note yet). +// 4. Shows an error view when getNote throws a DioException. +// 5. Retry button triggers a fresh getNote call after an error. +// 6. Auto-save fires after the 800 ms debounce when text changes. +// 7. Auto-save does NOT fire before the debounce delay elapses. +// 8. Manual "Save" menu item saves immediately without debounce. +// 9. "Clear" menu item shows a confirmation dialog. +// 10. Confirming the clear dialog calls deleteNote and empties the editor. +// 11. Cancelling the clear dialog leaves the editor unchanged. +// 12. notesErrorMessage unit tests (connection, 404, 500, generic). +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/notes_editor_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:go_router/go_router.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/notes_editor_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed test token. +/// +/// Avoids the platform-specific OS keychain in widget tests. +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 [NotesEditorScreen] tests. +/// +/// Implements [getNote], [upsertNote], and [deleteNote]; all other methods +/// remain [UnimplementedError] — the screen calls only these. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When set, [getNote] returns this value (may be null for "no note"). + Note? noteResult; + + /// When non-null, [getNote] throws this instead of returning. + Object? noteError; + + /// Captures the last content passed to [upsertNote]. + String? upsertedContent; + + /// Number of times [upsertNote] has been called. + int upsertCallCount = 0; + + /// Number of times [deleteNote] has been called. + int deleteCallCount = 0; + + /// Number of times [getNote] has been called. + int getNoteCallCount = 0; + + /// When non-null, [upsertNote] throws this instead of returning. + Object? upsertError; + + /// When non-null, [deleteNote] throws this instead of returning. + Object? deleteError; + + @override + Future getNote(int mediaId) async { + getNoteCallCount++; + if (noteError != null) throw noteError!; + return noteResult; + } + + @override + Future upsertNote(int mediaId, String content) async { + upsertCallCount++; + upsertedContent = content; + if (upsertError != null) throw upsertError!; + return Note( + id: 1, + mediaId: mediaId, + userId: 1, + content: content, + ); + } + + @override + Future deleteNote(int mediaId) async { + deleteCallCount++; + if (deleteError != null) throw deleteError!; + } +} + +/// [PlayerApiClient] stub that delays [getNote] until [complete] is called. +/// +/// Used to inspect mid-flight loading state before the response arrives. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer(); + + /// Resolves the pending [getNote] call with [note] (may be null). + void complete(Note? note) => _completer.complete(note); + + @override + Future getNote(int mediaId) => _completer.future; + + @override + Future upsertNote(int mediaId, String content) async { + return Note(id: 1, mediaId: mediaId, userId: 1, content: content); + } + + @override + Future deleteNote(int mediaId) async {} +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// A sample [Note] with pre-populated content. +const _kNote = Note( + id: 42, + mediaId: 7, + userId: 1, + content: 'Great scene at 01:23.', +); + +/// A DioException representing a network connectivity failure. +DioException _connectionError() => DioException( + requestOptions: RequestOptions(path: '/api/v1/media/7/notes'), + type: DioExceptionType.connectionError, + ); + +// --------------------------------------------------------------------------- +// Pump helper +// --------------------------------------------------------------------------- + +/// Pumps [NotesEditorScreen] inside a [ProviderScope] with overrides. +/// +/// [mediaId] defaults to '7' to match [_kNote]. The screen is mounted at +/// `/notes/7` so go_router can match the route if needed. +Future _pumpScreen( + WidgetTester tester, + PlayerApiClient fakeClient, { + String mediaId = '7', +}) async { + final router = GoRouter( + initialLocation: '/notes/$mediaId', + routes: [ + GoRoute( + path: '/notes/:mediaId', + builder: (_, state) => NotesEditorScreen( + mediaId: state.pathParameters['mediaId']!, + ), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while getNote is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpScreen(tester, fakeClient); + // Pump one frame: addPostFrameCallback fires but Future not yet resolved. + await tester.pump(); + + expect(find.byKey(const Key('notes_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsAtLeast(1)); + + // Resolve to avoid "async work pending" warnings at test teardown. + fakeClient.complete(_kNote); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Loads existing note + // -------------------------------------------------------------------------- + + group('loads existing note', () { + testWidgets('populates editor with note content on successful load', + (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The text field should contain the note's content. + expect(find.byKey(const Key('notes_text_field')), findsOneWidget); + expect(find.text('Great scene at 01:23.'), findsOneWidget); + }); + + testWidgets('shows empty editor when getNote returns null', (tester) async { + final fakeClient = _FakeApiClient()..noteResult = null; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notes_text_field')), findsOneWidget); + // The hint text is shown when the controller is empty. + expect(find.text('Write your notes here…'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error view when getNote throws', (tester) async { + final fakeClient = _FakeApiClient()..noteError = _connectionError(); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notes_error')), findsOneWidget); + expect(find.textContaining('Could not reach the server'), findsOneWidget); + }); + + testWidgets('retry button triggers a fresh getNote call', (tester) async { + final fakeClient = _FakeApiClient()..noteError = _connectionError(); + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notes_retry')), findsOneWidget); + + // Fix the error so the retry succeeds. + fakeClient + ..noteError = null + ..noteResult = _kNote; + + await tester.tap(find.byKey(const Key('notes_retry'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notes_text_field')), findsOneWidget); + expect(find.text('Great scene at 01:23.'), findsOneWidget); + // getNote was called twice: once on init, once on retry. + expect(fakeClient.getNoteCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // Auto-save (debounce) + // -------------------------------------------------------------------------- + + group('auto-save debounce', () { + testWidgets('auto-save fires after the debounce delay', (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Modify the text in the editor. + await tester.enterText( + find.byKey(const Key('notes_text_field')), + 'Updated note.', + ); + + // Before the debounce delay expires, no upsert should have been called. + await tester.pump(const Duration(milliseconds: 500)); + expect(fakeClient.upsertCallCount, equals(0)); + + // After 800 ms the auto-save fires. + await tester.pump(const Duration(milliseconds: 400)); + expect(fakeClient.upsertCallCount, equals(1)); + expect(fakeClient.upsertedContent, equals('Updated note.')); + }); + + testWidgets('auto-save does NOT fire before the debounce delay', + (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('notes_text_field')), + 'Typing…', + ); + + // 700 ms — still within the debounce window; no call yet. + await tester.pump(const Duration(milliseconds: 700)); + expect(fakeClient.upsertCallCount, equals(0)); + + // Let the timer expire cleanly to avoid "pending timers" warnings. + await tester.pump(const Duration(milliseconds: 200)); + }); + + testWidgets('auto-save does not fire when content is unchanged', + (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // The editor was seeded with _kNote.content; entering the same value + // should not trigger an upsert because _savedContent matches. + await tester.enterText( + find.byKey(const Key('notes_text_field')), + _kNote.content, + ); + + await tester.pump(const Duration(milliseconds: 1000)); + expect(fakeClient.upsertCallCount, equals(0)); + }); + }); + + // -------------------------------------------------------------------------- + // Manual Save + // -------------------------------------------------------------------------- + + group('manual save', () { + testWidgets('Save menu item calls upsertNote immediately', (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Change the text without settling: pump only one frame so the text + // change listener fires but the 800 ms debounce timer has NOT yet elapsed. + await tester.enterText( + find.byKey(const Key('notes_text_field')), + 'Manual save test.', + ); + // Pump a short duration well below the 800 ms debounce threshold so the + // timer has not fired yet — the menu tap below should be the only save. + await tester.pump(const Duration(milliseconds: 100)); + + // Open the overflow menu. Use pump (not pumpAndSettle) to avoid + // advancing the fake clock past the 800 ms debounce threshold. + await tester.tap(find.byKey(const Key('notes_overflow_menu'))); + await tester.pump(); + await tester.pump(); + + // Tap Save. _manualSave cancels the pending debounce timer first, so + // the subsequent pumpAndSettle will NOT fire the debounce callback — + // only the manual upsert call is made. + // warnIfMissed: false because popup menu items render outside the normal + // widget tree bounds in tests (they appear in an overlay). + await tester.tap( + find.byKey(const Key('notes_save_menu_item')), + warnIfMissed: false, + ); + await tester.pumpAndSettle(); + + expect(fakeClient.upsertCallCount, equals(1)); + expect(fakeClient.upsertedContent, equals('Manual save test.')); + }); + }); + + // -------------------------------------------------------------------------- + // Clear / delete + // -------------------------------------------------------------------------- + + group('clear note', () { + testWidgets('Clear menu item shows a confirmation dialog', (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_overflow_menu'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_clear_menu_item'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('notes_clear_dialog')), findsOneWidget); + }); + + testWidgets('confirming clear calls deleteNote and empties the editor', + (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_overflow_menu'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_clear_menu_item'))); + await tester.pumpAndSettle(); + + // Tap the "Clear" confirm button inside the dialog. + await tester.tap(find.byKey(const Key('notes_clear_confirm'))); + await tester.pumpAndSettle(); + + expect(fakeClient.deleteCallCount, equals(1)); + // Editor should now be empty. + final textField = tester.widget( + find.byKey(const Key('notes_text_field')), + ); + expect(textField.controller?.text, equals('')); + }); + + testWidgets('cancelling clear dialog leaves the editor unchanged', + (tester) async { + final fakeClient = _FakeApiClient()..noteResult = _kNote; + + await _pumpScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_overflow_menu'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('notes_clear_menu_item'))); + await tester.pumpAndSettle(); + + // Tap the "Cancel" button inside the dialog. + await tester.tap(find.byKey(const Key('notes_clear_cancel'))); + await tester.pumpAndSettle(); + + // deleteNote should NOT have been called. + expect(fakeClient.deleteCallCount, equals(0)); + // Editor content should be unchanged. + expect(find.text('Great scene at 01:23.'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // notesErrorMessage unit tests + // -------------------------------------------------------------------------- + + group('notesErrorMessage', () { + test('returns connectivity message for connectionError', () { + expect( + notesErrorMessage(_connectionError()), + contains('Could not reach the server'), + ); + }); + + test('returns "not found" message for 404 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/media/7/notes'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/media/7/notes'), + statusCode: 404, + ), + type: DioExceptionType.badResponse, + ); + expect(notesErrorMessage(err), contains('not found')); + }); + + test('returns server-error message for 500 badResponse', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/media/7/notes'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/media/7/notes'), + statusCode: 500, + ), + type: DioExceptionType.badResponse, + ); + expect(notesErrorMessage(err), contains('500')); + }); + + test('returns generic message for non-DioException', () { + expect( + notesErrorMessage(Exception('something broke')), + contains('Unexpected error'), + ); + }); + }); +} -- cgit v1.2.3