summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
Diffstat (limited to 'player-android')
-rw-r--r--player-android/lib/api/dio_player_api_client.dart43
-rw-r--r--player-android/lib/app_routes.dart7
-rw-r--r--player-android/lib/router.dart10
-rw-r--r--player-android/lib/screens/media_detail_screen.dart35
-rw-r--r--player-android/lib/screens/notes_editor_screen.dart461
-rw-r--r--player-android/lib/utils/error_mappers.dart17
-rw-r--r--player-android/test/screens/notes_editor_screen_test.dart506
7 files changed, 1074 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.';
+}
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<String?> readToken() async => 'test-token';
+
+ @override
+ Future<void> writeToken(String token) async {}
+
+ @override
+ Future<void> 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<Note?> getNote(int mediaId) async {
+ getNoteCallCount++;
+ if (noteError != null) throw noteError!;
+ return noteResult;
+ }
+
+ @override
+ Future<Note> 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<void> 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<Note?>();
+
+ /// Resolves the pending [getNote] call with [note] (may be null).
+ void complete(Note? note) => _completer.complete(note);
+
+ @override
+ Future<Note?> getNote(int mediaId) => _completer.future;
+
+ @override
+ Future<Note> upsertNote(int mediaId, String content) async {
+ return Note(id: 1, mediaId: mediaId, userId: 1, content: content);
+ }
+
+ @override
+ Future<void> 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<void> _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 cal