From e4a94d00c03732220de0b985dae58ace7bd8e9c9 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Sun, 21 Jun 2026 23:56:38 +0800 Subject: [PATCH] feat(editor): undo/redo, thumbnails, pen settings Per the full-refactor plan (P0/P1/P2 modules, all pressure-independent): - engine/undo_stack: generic snapshot undo/redo (per page in the editor) - ui/thumbnail_grid: Drawboard-style lazy thumbnail nav sheet (pdfrx) - input/pen_config + ui/pen_settings_page: configurable side-button / eraser-end action mapping, pressure curve, palm sensitivity, finger drawing, widths (shared_preferences). Button-action mappings persist but consume in the input arbiter later; widths/finger consumed now. Wired into the live editor (undo/redo + grid + settings buttons). 19 new tests. --- lib/editor/canvas/pen_editor_screen.dart | 148 ++++++++++- lib/editor/engine/undo_stack.dart | 75 ++++++ lib/editor/input/pen_config.dart | 218 ++++++++++++++++ lib/editor/ui/pen_settings_page.dart | 309 +++++++++++++++++++++++ lib/editor/ui/thumbnail_grid.dart | 235 +++++++++++++++++ test/pen_config_test.dart | 80 ++++++ test/undo_stack_test.dart | 148 +++++++++++ 7 files changed, 1206 insertions(+), 7 deletions(-) create mode 100644 lib/editor/engine/undo_stack.dart create mode 100644 lib/editor/input/pen_config.dart create mode 100644 lib/editor/ui/pen_settings_page.dart create mode 100644 lib/editor/ui/thumbnail_grid.dart create mode 100644 test/pen_config_test.dart create mode 100644 test/undo_stack_test.dart diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 86dda5f..42d2838 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -10,8 +10,12 @@ import 'package:pdfrx/pdfrx.dart'; import '../../services/database_service.dart'; import '../engine/stroke_model.dart'; +import '../engine/undo_stack.dart'; +import '../input/pen_config.dart'; import '../persistence/editor_repository.dart'; import '../persistence/save_scheduler.dart'; +import '../ui/pen_settings_page.dart'; +import '../ui/thumbnail_grid.dart'; import 'pen_canvas.dart'; import 'pen_stroke.dart'; @@ -47,6 +51,22 @@ class _PenEditorScreenState extends State { /// Strokes per page, keyed by 0-based page index (normalized coords). final Map> _strokesByPage = {}; + /// Per-page undo/redo history. Snapshot-before-change discipline: the + /// pre-mutation stroke list is recorded before each commit/erase. + final Map>> _undo = {}; + + UndoStack> _undoFor(int page) => + _undo.putIfAbsent(page, () => UndoStack>()); + + /// Pen input configuration (widths, finger drawing, button actions). + /// Loaded asynchronously in initState; null until ready. + /// + /// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd) + /// are persisted via this controller but NOT yet consumed here — they wire + /// into the input arbiter in a later step. Only widths and fingerDrawing are + /// consumed for now. + PenConfigController? _penConfig; + // ── Persistence ──────────────────────────────────────────────────────────── /// Stable document-id derived from the PDF file path. @@ -95,9 +115,25 @@ class _PenEditorScreenState extends State { super.initState(); _documentId = _documentIdFromPath(widget.pdfPath); _initPersistence(); + _initPenConfig(); _open(); } + Future _initPenConfig() async { + final controller = await PenConfigController.load(); + if (!mounted) { + controller.dispose(); + return; + } + setState(() { + _penConfig = controller; + // Adopt the persisted finger-drawing preference as the initial local + // toggle state. The local 🖐 toggle keeps working and stays in sync with + // the controller (see _toggleFingerDrawing). + _allowFingerDrawing = controller.value.fingerDrawing; + }); + } + Future _initPersistence() async { final service = await DatabaseService.getInstance(); if (!mounted) return; @@ -174,6 +210,7 @@ class _PenEditorScreenState extends State { } _document?.dispose(); _transform.dispose(); + _penConfig?.dispose(); super.dispose(); } @@ -181,6 +218,8 @@ class _PenEditorScreenState extends State { _strokesByPage.putIfAbsent(_pageIndex, () => []); void _commitStroke(PenStroke stroke) { + // Snapshot-before-change: record the pre-mutation page state for undo. + _undoFor(_pageIndex).record(List.of(_currentStrokes)); setState(() { // Replace with a NEW list so StaticInkPainter sees a fresh identity and // actually repaints (mutating in place would alias the old painter's list @@ -196,8 +235,13 @@ class _PenEditorScreenState extends State { } void _eraseStroke(int index) { + final list = _strokesByPage[_pageIndex]; + final willMutate = list != null && index >= 0 && index < list.length; + if (willMutate) { + // Snapshot-before-change: record the pre-mutation page state for undo. + _undoFor(_pageIndex).record(List.of(list)); + } setState(() { - final list = _strokesByPage[_pageIndex]; if (list != null && index >= 0 && index < list.length) { final next = List.of(list)..removeAt(index); _strokesByPage[_pageIndex] = next; @@ -237,6 +281,60 @@ class _PenEditorScreenState extends State { }); } + /// Undo the last draw/erase on the current page, restoring and persisting + /// the previous snapshot. + void _performUndo() { + final stack = _undoFor(_pageIndex); + if (!stack.canUndo) return; + final current = List.of(_currentStrokes); + final snapshot = stack.undo(current); + if (snapshot == null) return; + setState(() { + // New list identity so StaticInkPainter repaints. + _strokesByPage[_pageIndex] = List.of(snapshot); + }); + _schedulePageSave(_pageIndex, List.of(snapshot)); + } + + /// Redo the last undone draw/erase on the current page. + void _performRedo() { + final stack = _undoFor(_pageIndex); + if (!stack.canRedo) return; + final snapshot = stack.redo(); + if (snapshot == null) return; + setState(() { + _strokesByPage[_pageIndex] = List.of(snapshot); + }); + _schedulePageSave(_pageIndex, List.of(snapshot)); + } + + /// Toggle finger-drawing, keeping the local state and the persisted config + /// (when loaded) in sync. + void _toggleFingerDrawing() { + final next = !_allowFingerDrawing; + setState(() => _allowFingerDrawing = next); + _penConfig?.setFingerDrawing(next); + } + + /// Open the page thumbnail grid; tapping a thumbnail navigates to that page. + void _openThumbnails() { + final doc = _document; + if (doc == null) return; + showPageThumbnailSheet( + context, + document: doc, + currentPage: _pageIndex, + onPageSelected: _goToPage, + ); + } + + /// Open the pen settings sheet (widths, pressure, finger drawing, etc.). + void _openPenSettings() { + final config = _penConfig; + if (config == null) return; + showPenSettingsSheet(context, config); + } + /// Centre [pageSize] within [viewport] via the shared transform. void _centerPage(Size viewport, Size pageSize) { final tx = (viewport.width - pageSize.width) / 2; @@ -356,8 +454,9 @@ class _PenEditorScreenState extends State { tool: _tool, color: _color, strokeWidth: _tool == CanvasTool.highlighter - ? _highlighterWidthFraction - : _penWidthFraction, + ? (_penConfig?.value.highlighterWidth ?? + _highlighterWidthFraction) + : (_penConfig?.value.penWidth ?? _penWidthFraction), allowFingerDrawing: _allowFingerDrawing, onPenDebug: _showPenDebug ? (s) => setState(() => _penDebug = s) @@ -410,6 +509,20 @@ class _PenEditorScreenState extends State { onPressed: () => setState(() => _tool = CanvasTool.eraser), ), _Divider(cs: cs), + // Undo / redo (per page). + _ToolButton( + icon: Icons.undo, + selected: false, + tooltip: 'Undo', + onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null, + ), + _ToolButton( + icon: Icons.redo, + selected: false, + tooltip: 'Redo', + onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null, + ), + _Divider(cs: cs), for (final c in _palette) _colorDot(c, cs), _Divider(cs: cs), _ToolButton( @@ -418,8 +531,21 @@ class _PenEditorScreenState extends State { tooltip: _allowFingerDrawing ? 'Finger drawing ON' : 'Finger drawing OFF (pen only)', - onPressed: () => - setState(() => _allowFingerDrawing = !_allowFingerDrawing), + onPressed: _toggleFingerDrawing, + ), + // Page thumbnail grid. + _ToolButton( + icon: Icons.grid_view, + selected: false, + tooltip: 'Pages', + onPressed: _document != null ? _openThumbnails : null, + ), + // Pen settings. + _ToolButton( + icon: Icons.settings_outlined, + selected: false, + tooltip: 'Pen settings', + onPressed: _penConfig != null ? _openPenSettings : null, ), _ToolButton( icon: Icons.bug_report_outlined, @@ -549,11 +675,19 @@ class _ToolButton extends StatelessWidget { final IconData icon; final bool selected; final String tooltip; - final VoidCallback onPressed; + + /// Tap handler. When null the button renders disabled (dimmed, no ripple). + final VoidCallback? onPressed; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; + final enabled = onPressed != null; + final iconColor = !enabled + ? cs.onSurfaceVariant.withValues(alpha: 0.38) + : selected + ? cs.onSecondaryContainer + : cs.onSurfaceVariant; return Tooltip( message: tooltip, child: InkWell( @@ -570,7 +704,7 @@ class _ToolButton extends StatelessWidget { child: Icon( icon, size: 22, - color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant, + color: iconColor, ), ), ), diff --git a/lib/editor/engine/undo_stack.dart b/lib/editor/engine/undo_stack.dart new file mode 100644 index 0000000..5f86caa --- /dev/null +++ b/lib/editor/engine/undo_stack.dart @@ -0,0 +1,75 @@ +// lib/editor/engine/undo_stack.dart +// +// Generic undo/redo stack with a fixed capacity. +// +// RECORD DISCIPLINE: call `record(currentState)` BEFORE applying a mutation. +// The stack saves the pre-mutation snapshot so that `undo` can restore it. +// +// Example: +// final stack = UndoStack>(cap: 50); +// // User draws a stroke: +// stack.record(List.unmodifiable(strokes)); // snapshot before mutation +// strokes = [...strokes, newStroke]; // apply mutation +// +// Snapshots are treated as opaque, immutable values; the caller is responsible +// for passing copies/immutable lists rather than mutable references. + +/// A capped undo/redo stack for arbitrary snapshot types. +/// +/// Capacity defaults to 50 entries. When the cap is reached the oldest +/// undo snapshot is silently dropped to make room. +class UndoStack { + UndoStack({int cap = 50}) : _cap = cap; + + final int _cap; + + // Index 0 = oldest, last = most-recent snapshot available for undo. + final List _undoStack = []; + final List _redoStack = []; + + /// True when there is at least one snapshot that can be undone. + bool get canUndo => _undoStack.isNotEmpty; + + /// True when there is at least one snapshot that can be redone. + bool get canRedo => _redoStack.isNotEmpty; + + /// Save [snapshot] (the state BEFORE a mutation) onto the undo stack and + /// clear the redo stack (any branched future is discarded). + /// + /// If the stack is at capacity the oldest snapshot is dropped. + void record(T snapshot) { + if (_undoStack.length >= _cap) { + _undoStack.removeAt(0); + } + _undoStack.add(snapshot); + _redoStack.clear(); + } + + /// Undo the last recorded mutation. + /// + /// Returns the snapshot to restore, pushing [current] (the live state at + /// the moment of calling) onto the redo stack. Returns `null` if [canUndo] + /// is false. + T? undo(T current) { + if (!canUndo) return null; + _redoStack.add(current); + return _undoStack.removeLast(); + } + + /// Redo the last undone mutation. + /// + /// Returns the snapshot to restore and pushes it back onto the undo stack + /// so it can be undone again. Returns `null` if [canRedo] is false. + T? redo() { + if (!canRedo) return null; + final snapshot = _redoStack.removeLast(); + _undoStack.add(snapshot); + return snapshot; + } + + /// Clear both stacks. + void clear() { + _undoStack.clear(); + _redoStack.clear(); + } +} diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart new file mode 100644 index 0000000..88e6813 --- /dev/null +++ b/lib/editor/input/pen_config.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Action that can be triggered by a hardware pen button or the eraser end. +enum PenButtonAction { + none, + eraser, + undo, + toggleTool, + pan, +} + +/// Immutable configuration for pen input behaviour. +/// +/// Persisted under SharedPreferences key [PenConfigController.prefsKey]. +class PenConfig { + const PenConfig({ + this.sideButton = PenButtonAction.eraser, + this.eraserEnd = PenButtonAction.eraser, + this.pressureGamma = 1.0, + this.palmRejectionMs = 150.0, + this.fingerDrawing = false, + this.penWidth = 0.004, + this.highlighterWidth = 0.02, + }) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0, + 'pressureGamma must be in [0.3, 3.0]'), + assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0, + 'palmRejectionMs must be in [0, 500]'); + + /// Which action fires when the side barrel button is held. + final PenButtonAction sideButton; + + /// Which action fires when the eraser end of the pen is used. + final PenButtonAction eraserEnd; + + /// Gamma exponent for the pressure curve: effective = pressure ^ gamma. + /// Range: [0.3, 3.0], default 1.0 (linear). + final double pressureGamma; + + /// Duration in milliseconds after a pen-down event during which touch + /// contact is treated as palm and rejected. + /// Range: [0, 500], default 150. + final double palmRejectionMs; + + /// Whether finger touch strokes are drawn when no pen is present. + final bool fingerDrawing; + + /// Pen stroke width as a fraction of the canvas width. + final double penWidth; + + /// Highlighter stroke width as a fraction of the canvas width. + final double highlighterWidth; + + PenConfig copyWith({ + PenButtonAction? sideButton, + PenButtonAction? eraserEnd, + double? pressureGamma, + double? palmRejectionMs, + bool? fingerDrawing, + double? penWidth, + double? highlighterWidth, + }) { + return PenConfig( + sideButton: sideButton ?? this.sideButton, + eraserEnd: eraserEnd ?? this.eraserEnd, + pressureGamma: pressureGamma ?? this.pressureGamma, + palmRejectionMs: palmRejectionMs ?? this.palmRejectionMs, + fingerDrawing: fingerDrawing ?? this.fingerDrawing, + penWidth: penWidth ?? this.penWidth, + highlighterWidth: highlighterWidth ?? this.highlighterWidth, + ); + } + + Map toJson() => { + 'sideButton': sideButton.name, + 'eraserEnd': eraserEnd.name, + 'pressureGamma': pressureGamma, + 'palmRejectionMs': palmRejectionMs, + 'fingerDrawing': fingerDrawing, + 'penWidth': penWidth, + 'highlighterWidth': highlighterWidth, + }; + + factory PenConfig.fromJson(Map json) { + return PenConfig( + sideButton: + PenButtonAction.values.asNameMap()[json['sideButton'] as String? ?? ''] ?? + PenButtonAction.eraser, + eraserEnd: + PenButtonAction.values.asNameMap()[json['eraserEnd'] as String? ?? ''] ?? + PenButtonAction.eraser, + pressureGamma: (json['pressureGamma'] as num?)?.toDouble() ?? 1.0, + palmRejectionMs: (json['palmRejectionMs'] as num?)?.toDouble() ?? 150.0, + fingerDrawing: json['fingerDrawing'] as bool? ?? false, + penWidth: (json['penWidth'] as num?)?.toDouble() ?? 0.004, + highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PenConfig && + runtimeType == other.runtimeType && + sideButton == other.sideButton && + eraserEnd == other.eraserEnd && + pressureGamma == other.pressureGamma && + palmRejectionMs == other.palmRejectionMs && + fingerDrawing == other.fingerDrawing && + penWidth == other.penWidth && + highlighterWidth == other.highlighterWidth; + + @override + int get hashCode => Object.hash( + sideButton, + eraserEnd, + pressureGamma, + palmRejectionMs, + fingerDrawing, + penWidth, + highlighterWidth, + ); +} + +/// Applies a gamma power curve to a raw pressure value. +/// +/// Returns `pressure.clamp(0, 1) ^ gamma` as a [double]. +double applyPressureCurve(double pressure, double gamma) => + pow(pressure.clamp(0.0, 1.0), gamma).toDouble(); + +/// Manages [PenConfig] persistence and live updates. +/// +/// Load with [PenConfigController.load], then listen to changes via +/// [ChangeNotifier]. Setters immediately update the in-memory value, +/// notify listeners, and persist to SharedPreferences. +class PenConfigController extends ChangeNotifier { + PenConfigController._(this._prefs, this._value); + + /// The SharedPreferences key under which [PenConfig] JSON is stored. + static const prefsKey = 'pen_config_v1'; + + final SharedPreferences _prefs; + PenConfig _value; + + /// The current pen configuration. + PenConfig get value => _value; + + /// Loads persisted config from SharedPreferences. Falls back to defaults + /// if no config has been saved yet or if the stored JSON is invalid. + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(prefsKey); + PenConfig config; + if (raw == null) { + config = const PenConfig(); + } else { + try { + config = PenConfig.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + config = const PenConfig(); + } + } + return PenConfigController._(prefs, config); + } + + Future _persist() async { + await _prefs.setString(prefsKey, jsonEncode(_value.toJson())); + } + + Future setSideButton(PenButtonAction action) async { + _value = _value.copyWith(sideButton: action); + notifyListeners(); + await _persist(); + } + + Future setEraserEnd(PenButtonAction action) async { + _value = _value.copyWith(eraserEnd: action); + notifyListeners(); + await _persist(); + } + + /// Sets [PenConfig.pressureGamma]. Clamped to [0.3, 3.0]. + Future setPressureGamma(double gamma) async { + _value = _value.copyWith(pressureGamma: gamma.clamp(0.3, 3.0)); + notifyListeners(); + await _persist(); + } + + /// Sets [PenConfig.palmRejectionMs]. Clamped to [0, 500]. + Future setPalmRejectionMs(double ms) async { + _value = _value.copyWith(palmRejectionMs: ms.clamp(0.0, 500.0)); + notifyListeners(); + await _persist(); + } + + Future setFingerDrawing(bool enabled) async { + _value = _value.copyWith(fingerDrawing: enabled); + notifyListeners(); + await _persist(); + } + + Future setPenWidth(double width) async { + _value = _value.copyWith(penWidth: width); + notifyListeners(); + await _persist(); + } + + Future setHighlighterWidth(double width) async { + _value = _value.copyWith(highlighterWidth: width); + notifyListeners(); + await _persist(); + } +} diff --git a/lib/editor/ui/pen_settings_page.dart b/lib/editor/ui/pen_settings_page.dart new file mode 100644 index 0000000..3dc7dee --- /dev/null +++ b/lib/editor/ui/pen_settings_page.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; + +import '../input/pen_config.dart'; + +/// Shows a Material You modal bottom sheet for configuring pen input. +/// +/// Changes are applied and persisted immediately via [controller] setters, +/// so the sheet reflects the live configuration at all times. +Future showPenSettingsSheet( + BuildContext context, + PenConfigController controller, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (context) => _PenSettingsSheet(controller: controller), + ); +} + +class _PenSettingsSheet extends StatelessWidget { + const _PenSettingsSheet({required this.controller}); + + final PenConfigController controller; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final config = controller.value; + final colorScheme = Theme.of(context).colorScheme; + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (context, scrollController) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + // Drag handle + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withAlpha(80), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + 'Pen Settings', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // ── Button Actions ──────────────────────────────────────── + _SectionHeader( + title: 'Button Actions', + icon: Icons.touch_app, + colorScheme: colorScheme, + ), + const SizedBox(height: 8), + _LabeledRow( + label: 'Side Button', + child: _ActionDropdown( + value: config.sideButton, + onChanged: controller.setSideButton, + ), + ), + const SizedBox(height: 8), + _LabeledRow( + label: 'Eraser End', + child: _ActionDropdown( + value: config.eraserEnd, + onChanged: controller.setEraserEnd, + ), + ), + const SizedBox(height: 16), + + // ── Pressure ────────────────────────────────────────────── + _SectionHeader( + title: 'Pressure', + icon: Icons.compress, + colorScheme: colorScheme, + ), + _SliderTile( + label: 'Pressure Gamma', + value: config.pressureGamma, + min: 0.3, + max: 3.0, + divisions: 27, + formatValue: (v) => v.toStringAsFixed(2), + onChanged: controller.setPressureGamma, + ), + + // ── Input ───────────────────────────────────────────────── + _SectionHeader( + title: 'Input', + icon: Icons.pan_tool_alt, + colorScheme: colorScheme, + ), + _SliderTile( + label: 'Palm Rejection', + value: config.palmRejectionMs, + min: 0, + max: 500, + divisions: 50, + formatValue: (v) => '${v.round()} ms', + onChanged: controller.setPalmRejectionMs, + ), + SwitchListTile( + title: const Text('Finger Drawing'), + subtitle: const Text( + 'Allow touch strokes when no pen is detected', + ), + value: config.fingerDrawing, + onChanged: controller.setFingerDrawing, + contentPadding: EdgeInsets.zero, + ), + + // ── Stroke Widths ───────────────────────────────────────── + _SectionHeader( + title: 'Stroke Widths', + icon: Icons.line_weight, + colorScheme: colorScheme, + ), + _SliderTile( + label: 'Pen Width', + value: config.penWidth, + min: 0.001, + max: 0.02, + divisions: 19, + formatValue: (v) => v.toStringAsFixed(4), + onChanged: controller.setPenWidth, + ), + _SliderTile( + label: 'Highlighter Width', + value: config.highlighterWidth, + min: 0.005, + max: 0.06, + divisions: 22, + formatValue: (v) => v.toStringAsFixed(4), + onChanged: controller.setHighlighterWidth, + ), + ], + ); + }, + ); + }, + ); + } +} + +// ── Shared section header ──────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.icon, + required this.colorScheme, + }); + + final String title; + final IconData icon; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Row( + children: [ + Icon(icon, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Expanded(child: Divider(color: colorScheme.outlineVariant)), + ], + ), + ); + } +} + +// ── Label + widget row ─────────────────────────────────────────────────────── + +class _LabeledRow extends StatelessWidget { + const _LabeledRow({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded(child: child), + ], + ); + } +} + +// ── Action dropdown ────────────────────────────────────────────────────────── + +class _ActionDropdown extends StatelessWidget { + const _ActionDropdown({required this.value, required this.onChanged}); + + final PenButtonAction value; + final ValueChanged onChanged; + + static String _label(PenButtonAction action) => switch (action) { + PenButtonAction.none => 'None', + PenButtonAction.eraser => 'Eraser', + PenButtonAction.undo => 'Undo', + PenButtonAction.toggleTool => 'Toggle Tool', + PenButtonAction.pan => 'Pan', + }; + + @override + Widget build(BuildContext context) { + return DropdownMenu( + initialSelection: value, + expandedInsets: EdgeInsets.zero, + onSelected: (action) { + if (action != null) onChanged(action); + }, + dropdownMenuEntries: PenButtonAction.values + .map( + (a) => DropdownMenuEntry(value: a, label: _label(a)), + ) + .toList(), + ); + } +} + +// ── Slider with live value label ───────────────────────────────────────────── + +class _SliderTile extends StatelessWidget { + const _SliderTile({ + required this.label, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.formatValue, + required this.onChanged, + }); + + final String label; + final double value; + final double min; + final double max; + final int divisions; + final String Function(double) formatValue; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), + Text( + formatValue(value), + style: TextStyle( + fontFamily: 'monospace', + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + Slider( + value: value.clamp(min, max), + min: min, + max: max, + divisions: divisions, + label: formatValue(value), + onChanged: onChanged, + ), + ], + ); + } +} diff --git a/lib/editor/ui/thumbnail_grid.dart b/lib/editor/ui/thumbnail_grid.dart new file mode 100644 index 0000000..f876c65 --- /dev/null +++ b/lib/editor/ui/thumbnail_grid.dart @@ -0,0 +1,235 @@ +// lib/editor/ui/thumbnail_grid.dart +// +// Drawboard-style page thumbnail grid for BadNote. +// Shows lazy GridView of PDF page thumbnails via PdfPageView. +// Used as a modal bottom sheet via showPageThumbnailSheet(). + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:pdfrx/pdfrx.dart'; + +/// A lazy grid of page thumbnails for a PDF document. +/// +/// [document] — the open PdfDocument. +/// [currentPage] — 0-based index of the currently active page. +/// [onPageSelected] — called with the 0-based page index when the user taps a thumbnail. +class PageThumbnailGrid extends StatefulWidget { + const PageThumbnailGrid({ + super.key, + required this.document, + required this.currentPage, + required this.onPageSelected, + }); + + final PdfDocument document; + final int currentPage; + final ValueChanged onPageSelected; + + @override + State createState() => _PageThumbnailGridState(); +} + +class _PageThumbnailGridState extends State { + late final ScrollController _scrollController; + + /// Approximate tile height used for the initial jump calculation. + /// The grid uses a cross-axis count of 3, so tile width ≈ screenWidth/3. + /// We rely on a fixed thumbnail width of ~120 logical pixels so the + /// aspect ratio (A4 ≈ 1:√2) gives us roughly 170 px tall per tile. + static const double _tileHeightEstimate = 170.0; + static const double _crossAxisCount = 3; + static const double _thumbnailWidth = 120.0; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController(); + SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage()); + } + + @override + void didUpdateWidget(covariant PageThumbnailGrid oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentPage != widget.currentPage) { + SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage()); + } + } + + void _jumpToCurrentPage() { + if (!_scrollController.hasClients) return; + final row = widget.currentPage ~/ _crossAxisCount.toInt(); + final offset = row * _tileHeightEstimate; + final maxOffset = _scrollController.position.maxScrollExtent; + _scrollController.jumpTo(offset.clamp(0.0, maxOffset)); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final pageCount = widget.document.pages.length; + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(12), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: _thumbnailWidth + 24, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: _thumbnailWidth / _tileHeightEstimate, + ), + itemCount: pageCount, + itemBuilder: (context, index) { + return _ThumbnailTile( + document: widget.document, + pageIndex: index, + isSelected: index == widget.currentPage, + onTap: () => widget.onPageSelected(index), + ); + }, + ); + } +} + +class _ThumbnailTile extends StatelessWidget { + const _ThumbnailTile({ + required this.document, + required this.pageIndex, + required this.isSelected, + required this.onTap, + }); + + final PdfDocument document; + final int pageIndex; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: PdfPageView( + document: document, + pageNumber: pageIndex + 1, // PdfPageView is 1-based + backgroundColor: colorScheme.surface, + decoration: const BoxDecoration(color: Colors.white), + ), + ), + Container( + padding: const EdgeInsets.symmetric(vertical: 4), + color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh, + child: Text( + '${pageIndex + 1}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Shows a Material You modal bottom sheet containing a [PageThumbnailGrid]. +/// +/// [document] — the open PdfDocument. +/// [currentPage] — 0-based index of the currently active page. +/// [onPageSelected] — called with the 0-based page index when the user selects +/// a thumbnail; the sheet is automatically dismissed afterwards. +Future showPageThumbnailSheet( + BuildContext context, { + required PdfDocument document, + required int currentPage, + required ValueChanged onPageSelected, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + expand: false, + builder: (_, scrollController) { + return Column( + children: [ + // Drag handle + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(sheetContext).colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 8, 8), + child: Row( + children: [ + Text( + 'Pages', + style: Theme.of(sheetContext).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Close', + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ], + ), + ), + const Divider(height: 1), + // Thumbnail grid — fill remaining height + Expanded( + child: PageThumbnailGrid( + document: document, + currentPage: currentPage, + onPageSelected: (index) { + Navigator.of(sheetContext).pop(); + onPageSelected(index); + }, + ), + ), + ], + ); + }, + ); + }, + ); +} diff --git a/test/pen_config_test.dart b/test/pen_config_test.dart new file mode 100644 index 0000000..7132ab5 --- /dev/null +++ b/test/pen_config_test.dart @@ -0,0 +1,80 @@ +// test/pen_config_test.dart +// +// Unit tests for PenConfig (toJson/fromJson round-trip) and +// applyPressureCurve. No widgets, no SharedPreferences, no Flutter framework. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:badnote/editor/input/pen_config.dart'; + +void main() { + group('applyPressureCurve', () { + test('gamma=1 is identity', () { + expect(applyPressureCurve(0.5, 1.0), closeTo(0.5, 1e-9)); + expect(applyPressureCurve(0.0, 1.0), closeTo(0.0, 1e-9)); + expect(applyPressureCurve(1.0, 1.0), closeTo(1.0, 1e-9)); + }); + + test('applyPressureCurve(0.5, 2.0) == 0.25', () { + expect(applyPressureCurve(0.5, 2.0), closeTo(0.25, 1e-9)); + }); + + test('clamps input below 0', () { + expect(applyPressureCurve(-0.5, 1.0), closeTo(0.0, 1e-9)); + }); + + test('clamps input above 1', () { + expect(applyPressureCurve(1.5, 1.0), closeTo(1.0, 1e-9)); + }); + }); + + group('PenConfig toJson / fromJson round-trip', () { + test('default config survives round-trip', () { + const original = PenConfig(); + final restored = PenConfig.fromJson(original.toJson()); + expect(restored, equals(original)); + }); + + test('custom config survives round-trip', () { + const original = PenConfig( + sideButton: PenButtonAction.undo, + eraserEnd: PenButtonAction.pan, + pressureGamma: 1.5, + palmRejectionMs: 200.0, + fingerDrawing: true, + penWidth: 0.008, + highlighterWidth: 0.03, + ); + final restored = PenConfig.fromJson(original.toJson()); + expect(restored, equals(original)); + }); + + test('fromJson falls back to defaults for missing keys', () { + final restored = PenConfig.fromJson({}); + expect(restored.sideButton, PenButtonAction.eraser); + expect(restored.eraserEnd, PenButtonAction.eraser); + expect(restored.pressureGamma, 1.0); + expect(restored.palmRejectionMs, 150.0); + expect(restored.fingerDrawing, false); + expect(restored.penWidth, 0.004); + expect(restored.highlighterWidth, 0.02); + }); + + test('fromJson falls back to defaults for unknown enum names', () { + final restored = PenConfig.fromJson({ + 'sideButton': 'unknownAction', + 'eraserEnd': 'anotherUnknown', + }); + expect(restored.sideButton, PenButtonAction.eraser); + expect(restored.eraserEnd, PenButtonAction.eraser); + }); + }); + + group('PenConfig copyWith', () { + test('copyWith preserves unchanged fields', () { + const original = PenConfig(pressureGamma: 2.0); + final copy = original.copyWith(fingerDrawing: true); + expect(copy.pressureGamma, 2.0); + expect(copy.fingerDrawing, true); + }); + }); +} diff --git a/test/undo_stack_test.dart b/test/undo_stack_test.dart new file mode 100644 index 0000000..2272784 --- /dev/null +++ b/test/undo_stack_test.dart @@ -0,0 +1,148 @@ +// test/undo_stack_test.dart +// +// Pure unit tests for UndoStack. No widgets, no DB, no Flutter framework. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:badnote/editor/engine/undo_stack.dart'; + +void main() { + group('UndoStack', () { + // ------------------------------------------------------------------ basics + test('starts empty with canUndo=false and canRedo=false', () { + final stack = UndoStack(); + expect(stack.canUndo, isFalse); + expect(stack.canRedo, isFalse); + }); + + test('undo on empty stack returns null', () { + final stack = UndoStack(); + expect(stack.undo(42), isNull); + }); + + test('redo on empty stack returns null', () { + final stack = UndoStack(); + expect(stack.redo(), isNull); + }); + + // -------------------------------------------- record / undo / redo cycle + test('record A, B, C then undo yields B then A', () { + final stack = UndoStack(); + + // Simulate: current starts at A=1 + // Before mutating to B=2 we record the pre-mutation state A=1 + stack.record(1); // snapshot before →2 + // Before mutating to C=3 we record the pre-mutation state B=2 + stack.record(2); // snapshot before →3 + // Current live state is C=3 + + expect(stack.canUndo, isTrue); + expect(stack.canRedo, isFalse); + + // First undo: push current(3) to redo, pop B(2) from undo + expect(stack.undo(3), equals(2)); + expect(stack.canUndo, isTrue); + expect(stack.canRedo, isTrue); + + // Second undo: push current(2) to redo, pop A(1) from undo + expect(stack.undo(2), equals(1)); + expect(stack.canUndo, isFalse); + expect(stack.canRedo, isTrue); + }); + + test('redo after undo restores B', () { + final stack = UndoStack(); + stack.record(1); + stack.record(2); + stack.undo(3); // restore →2 + stack.undo(2); // restore →1 + + // First redo: pop 2 from redo, push back to undo + expect(stack.redo(), equals(2)); + expect(stack.canRedo, isTrue); + + // Second redo: pop 3 from redo + expect(stack.redo(), equals(3)); + expect(stack.canRedo, isFalse); + }); + + // ------------------------------------------ record after undo clears redo + test('record after undo clears the redo stack', () { + final stack = UndoStack(); + stack.record(1); // before →2 + stack.record(2); // before →3 + stack.undo(3); // back to 2, redo has [3] + + expect(stack.canRedo, isTrue); + + // Now record a NEW snapshot (branching from current=2) + stack.record(2); // before →99 + // Redo stack must be cleared + expect(stack.canRedo, isFalse); + expect(stack.canUndo, isTrue); + }); + + // ------------------------------------------------ capacity / cap behavior + test('cap drops oldest snapshots beyond capacity', () { + const cap = 3; + final stack = UndoStack(cap: cap); + + // Record 4 snapshots — only the last 3 should survive + stack.record(10); + stack.record(20); + stack.record(30); + stack.record(40); // oldest (10) dropped + + // Undo 3 times from current=50 + expect(stack.undo(50), equals(40)); // most recent + expect(stack.undo(40), equals(30)); + expect(stack.undo(30), equals(20)); + // Stack is now empty — 10 was dropped + expect(stack.canUndo, isFalse); + }); + + // --------------------------------------------------- clear + test('clear empties both stacks', () { + final stack = UndoStack(); + stack.record(1); + stack.record(2); + stack.undo(3); + + stack.clear(); + + expect(stack.canUndo, isFalse); + expect(stack.canRedo, isFalse); + }); + + // ---------------------------------------- works with List snapshots + test('works with List snapshots (immutable caller discipline)', () { + final stack = UndoStack>(); + + final a = List.unmodifiable([1, 2]); + final b = List.unmodifiable([1, 2, 3]); + + stack.record(a); // before mutation to b + // live state is now b + + final restored = stack.undo(b); + expect(restored, equals(a)); + expect(stack.canUndo, isFalse); + expect(stack.canRedo, isTrue); + }); + + // ------------------------------------------ redo pushes back to undo + test('redo snapshot is re-undoable (undo stack grows back)', () { + final stack = UndoStack(); + // undo=[1], redo=[] + stack.record(1); // before →2 + // undo=[], redo=[2] + stack.undo(2); // live=1 + // undo=[2], redo=[] + stack.redo(); // live=2 + // canUndo must be true because redo() pushed 2 back onto undo + expect(stack.canUndo, isTrue); + expect(stack.canRedo, isFalse); + // Undoing from a new live state 3 pops 2 off undo + expect(stack.undo(3), equals(2)); + }); + }); +}