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