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 '../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<PenEditorScreen> {
|
||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Stable document-id derived from the PDF file path.
|
||||
@@ -95,9 +115,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
super.initState();
|
||||
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||
_initPersistence();
|
||||
_initPenConfig();
|
||||
_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 {
|
||||
final service = await DatabaseService.getInstance();
|
||||
if (!mounted) return;
|
||||
@@ -174,6 +210,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
_document?.dispose();
|
||||
_transform.dispose();
|
||||
_penConfig?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -181,6 +218,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||||
|
||||
void _commitStroke(PenStroke stroke) {
|
||||
// Snapshot-before-change: record the pre-mutation page state for undo.
|
||||
_undoFor(_pageIndex).record(List<PenStroke>.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<PenEditorScreen> {
|
||||
}
|
||||
|
||||
void _eraseStroke(int index) {
|
||||
setState(() {
|
||||
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(() {
|
||||
if (list != null && index >= 0 && index < list.length) {
|
||||
final next = List<PenStroke>.of(list)..removeAt(index);
|
||||
_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.
|
||||
void _centerPage(Size viewport, Size pageSize) {
|
||||
final tx = (viewport.width - pageSize.width) / 2;
|
||||
@@ -356,8 +454,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
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<PenEditorScreen> {
|
||||
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<PenEditorScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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