summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/notes_editor_screen.dart
blob: 88946efaac15a5c3d362977927b355cdc408b14b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
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'),
            ),
          ],
        ),
      ),
    );
  }
}