feat(editor): undo/redo, thumbnails, pen settings
All checks were successful
CI / Windows build (push) Successful in 8m39s
All checks were successful
CI / Windows build (push) Successful in 8m39s
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.
This commit is contained in:
@@ -10,8 +10,12 @@ import 'package:pdfrx/pdfrx.dart';
|
|||||||
|
|
||||||
import '../../services/database_service.dart';
|
import '../../services/database_service.dart';
|
||||||
import '../engine/stroke_model.dart';
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../engine/undo_stack.dart';
|
||||||
|
import '../input/pen_config.dart';
|
||||||
import '../persistence/editor_repository.dart';
|
import '../persistence/editor_repository.dart';
|
||||||
import '../persistence/save_scheduler.dart';
|
import '../persistence/save_scheduler.dart';
|
||||||
|
import '../ui/pen_settings_page.dart';
|
||||||
|
import '../ui/thumbnail_grid.dart';
|
||||||
import 'pen_canvas.dart';
|
import 'pen_canvas.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
@@ -47,6 +51,22 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
|
|
||||||
|
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
||||||
|
/// pre-mutation stroke list is recorded before each commit/erase.
|
||||||
|
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
|
||||||
|
|
||||||
|
UndoStack<List<PenStroke>> _undoFor(int page) =>
|
||||||
|
_undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());
|
||||||
|
|
||||||
|
/// 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 ────────────────────────────────────────────────────────────
|
// ── Persistence ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Stable document-id derived from the PDF file path.
|
/// Stable document-id derived from the PDF file path.
|
||||||
@@ -95,9 +115,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_documentId = _documentIdFromPath(widget.pdfPath);
|
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||||
_initPersistence();
|
_initPersistence();
|
||||||
|
_initPenConfig();
|
||||||
_open();
|
_open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _initPersistence() async {
|
Future<void> _initPersistence() async {
|
||||||
final service = await DatabaseService.getInstance();
|
final service = await DatabaseService.getInstance();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -174,6 +210,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
_document?.dispose();
|
_document?.dispose();
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
|
_penConfig?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +218,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||||||
|
|
||||||
void _commitStroke(PenStroke stroke) {
|
void _commitStroke(PenStroke stroke) {
|
||||||
|
// Snapshot-before-change: record the pre-mutation page state for undo.
|
||||||
|
_undoFor(_pageIndex).record(List<PenStroke>.of(_currentStrokes));
|
||||||
setState(() {
|
setState(() {
|
||||||
// Replace with a NEW list so StaticInkPainter sees a fresh identity and
|
// Replace with a NEW list so StaticInkPainter sees a fresh identity and
|
||||||
// actually repaints (mutating in place would alias the old painter's list
|
// actually repaints (mutating in place would alias the old painter's list
|
||||||
@@ -196,8 +235,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _eraseStroke(int index) {
|
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<PenStroke>.of(list));
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
final list = _strokesByPage[_pageIndex];
|
|
||||||
if (list != null && index >= 0 && index < list.length) {
|
if (list != null && index >= 0 && index < list.length) {
|
||||||
final next = List<PenStroke>.of(list)..removeAt(index);
|
final next = List<PenStroke>.of(list)..removeAt(index);
|
||||||
_strokesByPage[_pageIndex] = next;
|
_strokesByPage[_pageIndex] = next;
|
||||||
@@ -237,6 +281,60 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<PenStroke>.of(_currentStrokes);
|
||||||
|
final snapshot = stack.undo(current);
|
||||||
|
if (snapshot == null) return;
|
||||||
|
setState(() {
|
||||||
|
// New list identity so StaticInkPainter repaints.
|
||||||
|
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
||||||
|
});
|
||||||
|
_schedulePageSave(_pageIndex, List<PenStroke>.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<PenStroke>.of(snapshot);
|
||||||
|
});
|
||||||
|
_schedulePageSave(_pageIndex, List<PenStroke>.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.
|
/// Centre [pageSize] within [viewport] via the shared transform.
|
||||||
void _centerPage(Size viewport, Size pageSize) {
|
void _centerPage(Size viewport, Size pageSize) {
|
||||||
final tx = (viewport.width - pageSize.width) / 2;
|
final tx = (viewport.width - pageSize.width) / 2;
|
||||||
@@ -356,8 +454,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
tool: _tool,
|
tool: _tool,
|
||||||
color: _color,
|
color: _color,
|
||||||
strokeWidth: _tool == CanvasTool.highlighter
|
strokeWidth: _tool == CanvasTool.highlighter
|
||||||
? _highlighterWidthFraction
|
? (_penConfig?.value.highlighterWidth ??
|
||||||
: _penWidthFraction,
|
_highlighterWidthFraction)
|
||||||
|
: (_penConfig?.value.penWidth ?? _penWidthFraction),
|
||||||
allowFingerDrawing: _allowFingerDrawing,
|
allowFingerDrawing: _allowFingerDrawing,
|
||||||
onPenDebug: _showPenDebug
|
onPenDebug: _showPenDebug
|
||||||
? (s) => setState(() => _penDebug = s)
|
? (s) => setState(() => _penDebug = s)
|
||||||
@@ -410,6 +509,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
),
|
),
|
||||||
_Divider(cs: cs),
|
_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),
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
_Divider(cs: cs),
|
_Divider(cs: cs),
|
||||||
_ToolButton(
|
_ToolButton(
|
||||||
@@ -418,8 +531,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
tooltip: _allowFingerDrawing
|
tooltip: _allowFingerDrawing
|
||||||
? 'Finger drawing ON'
|
? 'Finger drawing ON'
|
||||||
: 'Finger drawing OFF (pen only)',
|
: 'Finger drawing OFF (pen only)',
|
||||||
onPressed: () =>
|
onPressed: _toggleFingerDrawing,
|
||||||
setState(() => _allowFingerDrawing = !_allowFingerDrawing),
|
),
|
||||||
|
// 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(
|
_ToolButton(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
@@ -549,11 +675,19 @@ class _ToolButton extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final String tooltip;
|
final String tooltip;
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
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(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -570,7 +704,7 @@ class _ToolButton extends StatelessWidget {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 22,
|
size: 22,
|
||||||
color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
75
lib/editor/engine/undo_stack.dart
Normal file
75
lib/editor/engine/undo_stack.dart
Normal file
@@ -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<List<PenStroke>>(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<T> {
|
||||||
|
UndoStack({int cap = 50}) : _cap = cap;
|
||||||
|
|
||||||
|
final int _cap;
|
||||||
|
|
||||||
|
// Index 0 = oldest, last = most-recent snapshot available for undo.
|
||||||
|
final List<T> _undoStack = [];
|
||||||
|
final List<T> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
218
lib/editor/input/pen_config.dart
Normal file
218
lib/editor/input/pen_config.dart
Normal file
@@ -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<String, dynamic> toJson() => {
|
||||||
|
'sideButton': sideButton.name,
|
||||||
|
'eraserEnd': eraserEnd.name,
|
||||||
|
'pressureGamma': pressureGamma,
|
||||||
|
'palmRejectionMs': palmRejectionMs,
|
||||||
|
'fingerDrawing': fingerDrawing,
|
||||||
|
'penWidth': penWidth,
|
||||||
|
'highlighterWidth': highlighterWidth,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory PenConfig.fromJson(Map<String, dynamic> 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<PenConfigController> 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<String, dynamic>,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
config = const PenConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PenConfigController._(prefs, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
await _prefs.setString(prefsKey, jsonEncode(_value.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSideButton(PenButtonAction action) async {
|
||||||
|
_value = _value.copyWith(sideButton: action);
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setEraserEnd(PenButtonAction action) async {
|
||||||
|
_value = _value.copyWith(eraserEnd: action);
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets [PenConfig.pressureGamma]. Clamped to [0.3, 3.0].
|
||||||
|
Future<void> 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<void> setPalmRejectionMs(double ms) async {
|
||||||
|
_value = _value.copyWith(palmRejectionMs: ms.clamp(0.0, 500.0));
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setFingerDrawing(bool enabled) async {
|
||||||
|
_value = _value.copyWith(fingerDrawing: enabled);
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPenWidth(double width) async {
|
||||||
|
_value = _value.copyWith(penWidth: width);
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHighlighterWidth(double width) async {
|
||||||
|
_value = _value.copyWith(highlighterWidth: width);
|
||||||
|
notifyListeners();
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
309
lib/editor/ui/pen_settings_page.dart
Normal file
309
lib/editor/ui/pen_settings_page.dart
Normal file
@@ -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<void> showPenSettingsSheet(
|
||||||
|
BuildContext context,
|
||||||
|
PenConfigController controller,
|
||||||
|
) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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<PenButtonAction> 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<PenButtonAction>(
|
||||||
|
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<double> 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
lib/editor/ui/thumbnail_grid.dart
Normal file
235
lib/editor/ui/thumbnail_grid.dart
Normal file
@@ -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<int> onPageSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PageThumbnailGrid> createState() => _PageThumbnailGridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PageThumbnailGridState extends State<PageThumbnailGrid> {
|
||||||
|
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<void> showPageThumbnailSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required PdfDocument document,
|
||||||
|
required int currentPage,
|
||||||
|
required ValueChanged<int> onPageSelected,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
80
test/pen_config_test.dart
Normal file
80
test/pen_config_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
148
test/undo_stack_test.dart
Normal file
148
test/undo_stack_test.dart
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// test/undo_stack_test.dart
|
||||||
|
//
|
||||||
|
// Pure unit tests for UndoStack<T>. 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<int>();
|
||||||
|
expect(stack.canUndo, isFalse);
|
||||||
|
expect(stack.canRedo, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo on empty stack returns null', () {
|
||||||
|
final stack = UndoStack<int>();
|
||||||
|
expect(stack.undo(42), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redo on empty stack returns null', () {
|
||||||
|
final stack = UndoStack<int>();
|
||||||
|
expect(stack.redo(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------- record / undo / redo cycle
|
||||||
|
test('record A, B, C then undo yields B then A', () {
|
||||||
|
final stack = UndoStack<int>();
|
||||||
|
|
||||||
|
// 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<int>();
|
||||||
|
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<int>();
|
||||||
|
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<int>(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<int>();
|
||||||
|
stack.record(1);
|
||||||
|
stack.record(2);
|
||||||
|
stack.undo(3);
|
||||||
|
|
||||||
|
stack.clear();
|
||||||
|
|
||||||
|
expect(stack.canUndo, isFalse);
|
||||||
|
expect(stack.canRedo, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------- works with List<int> snapshots
|
||||||
|
test('works with List<int> snapshots (immutable caller discipline)', () {
|
||||||
|
final stack = UndoStack<List<int>>();
|
||||||
|
|
||||||
|
final a = List<int>.unmodifiable([1, 2]);
|
||||||
|
final b = List<int>.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<int>();
|
||||||
|
// 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user