feat(editor): undo/redo, thumbnails, pen settings
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:
2026-06-21 23:56:38 +08:00
parent 28b7af7e72
commit e4a94d00c0
7 changed files with 1206 additions and 7 deletions

View File

@@ -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) {
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(() {
final list = _strokesByPage[_pageIndex];
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,
),
),
),

View 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();
}
}

View 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();
}
}

View 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,
),
],
);
}
}

View 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
View 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
View 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));
});
});
}