feat(note): rebuild note editor on the pen-first canvas
Some checks failed
CI / Windows build (push) Has been cancelled

Notes now use the single performant inking engine (PenCanvas) instead of
the old ink_canvas, per "all note features on the pen-first canvas".

- ink_stroke_adapter: pure InkStroke<->PenStroke bridge (normalize against
  a logical note page; drop non-freehand shapes/text). Round-trip tested.
- pen_palette_widgets: shared M3 ToolButton/PaletteDivider/RoundIconButton
  so PDF + note editors use identical chrome (PenEditorScreen migrated to
  them; its private copies deleted).
- PenNoteScreen: PenCanvas over a white logical page, undo/redo, title,
  save -> Note.strokes (+ local OCR for search). Pressure curve, eraser
  size/mode and palm rejection all inherited from the shared canvas.
- Route home (new/open) + search note hits -> PenNoteScreen; remove the
  now-redundant "Pen Canvas (beta)" spike button; delete the dead old
  note_editor_screen.

Tests: ink_stroke_adapter (5) + pen_note_screen widget (load + commit, 2).
flutter analyze: 0 issues. Full suite: 265/265.
This commit is contained in:
2026-06-23 10:21:40 +08:00
parent 3507e929b1
commit dfe5f2a477
12 changed files with 8769 additions and 418 deletions

View File

@@ -0,0 +1,33 @@
# PUA Loop — status (BadNote 整体重构)
## Oracle: `flutter analyze lib/editor && flutter test` → GREEN, exit 0, 145/145.
## Delivered this loop (all committed + pushed, 6829076..eedb52d)
P0 completion + P0.5 automatable layer + bonus pure cores:
- 6829076 zoom absolute-snapshot fix + pen streamline (P0/live)
- a48c0e7 input_arbiter (P0 step4)
- d500872 export single-source recipe / R7 (P0 step7)
- f64e656 SaveScheduler tests (P0 step8)
- eca5141 live canvas → revision-gated ui.Picture cache (P0 step3)
- 07b543f PageTileCache DPI-bucketed (P0.5)
- 1a3d106 PageStackMetrics windowing (P0.5)
- 6b7cc14 PageDocumentSource + fit-to-width glue (P0.5)
- c03513d PdfrxPageDocumentSource + API source-pin (P0.5 step12/SF4)
- c31bfd3 navigation math current-page/scroll-clamp (P0.5)
- 852eb38 双链 link_graph pure core (F7)
- eedb52d pressure curve floor+gamma (F5)
## THE BLOCKER (honest)
The refactor's CRITICAL PATH is device validation, which only the user's Surface
can provide and which the plan ITSELF gates on:
- P0 step9: pen/palm/pinch on Surface (build eedb52d).
- P0.5 exit: crisp-at-4× + 60fps profile — no automatable acceptance test exists.
The remaining work (page_tile renderer, page_viewport WIDGET, perf bench) is
device/GPU-gated; writing it blind = a claim with no acceptance evidence.
## Options for the user
1. Device-test eedb52d (zoom/pen/render/export) → I wire the P0.5 pure pieces
into the viewport widget and push the P0.5 device gate.
2. Tell me to keep PRE-BUILDING unwired pure cores (F6 page-map, F8 snippet
extraction, more F5/F7) — real + tested, but NOT on the blocked critical path.
3. /pua:cancel-pua-loop to end the loop.

View File

@@ -0,0 +1,3 @@
{"iteration":0,"status":"init","verify_command":"flutter analyze","timestamp":"2026-06-23T01:25:48Z"}
{"iteration":1,"status":"continue","timestamp":"2026-06-23T01:57:58Z"}
{"iteration":2,"status":"continue","timestamp":"2026-06-23T02:07:19Z"}

7888
badnote_input_log-2.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'input_diagnostics.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
/// Stable deterministic document-id for a file path (djb2 hash → hex).
@@ -444,7 +445,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: _RoundIconButton(
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: l.back,
onPressed: () => Navigator.of(context).maybePop(),
@@ -620,42 +621,42 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToolButton(
ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: l.toolPen,
onPressed: () => setState(() => _tool = CanvasTool.pen),
),
_ToolButton(
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: l.toolHighlighter,
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
),
_ToolButton(
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: l.toolEraser,
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
_Divider(cs: cs),
PaletteDivider(cs: cs),
// Undo / redo (per page).
_ToolButton(
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: l.actionUndo,
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
),
_ToolButton(
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: l.actionRedo,
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
),
_Divider(cs: cs),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
_Divider(cs: cs),
_ToolButton(
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
@@ -664,20 +665,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
onPressed: _toggleFingerDrawing,
),
// Page thumbnail grid.
_ToolButton(
ToolButton(
icon: Icons.grid_view,
selected: false,
tooltip: l.pages,
onPressed: _document != null ? _openThumbnails : null,
),
// Pen settings.
_ToolButton(
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: l.penSettings,
onPressed: _penConfig != null ? _openPenSettings : null,
),
_ToolButton(
ToolButton(
icon: Icons.bug_report_outlined,
selected: _showPenDebug,
tooltip: l.inputDiagnostic,
@@ -803,92 +804,3 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
}
/// A Material You toggle-style icon button for the tool palette.
class _ToolButton extends StatelessWidget {
const _ToolButton({
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// 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(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
size: 22,
color: iconColor,
),
),
),
);
}
}
class _Divider extends StatelessWidget {
const _Divider({required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class _RoundIconButton extends StatelessWidget {
const _RoundIconButton({
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,459 @@
// lib/editor/canvas/pen_note_screen.dart
//
// Pen-first blank-note editor. Reuses the single performant inking engine
// (PenCanvas) over a white logical page instead of a PDF page, and persists
// strokes back to the Note model via the InkStroke<->PenStroke adapter. This is
// the note half of "all note features on the pen-first canvas".
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../../models/ink_stroke.dart';
import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../notebook/ink_stroke_adapter.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenNoteScreen extends ConsumerStatefulWidget {
const PenNoteScreen({super.key, this.note});
/// Existing note to edit, or null for a new note.
final Note? note;
@override
ConsumerState<PenNoteScreen> createState() => _PenNoteScreenState();
}
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
static const _uuid = Uuid();
/// Live strokes in normalized coords (the canvas source of truth). Persisted
/// back to the note as InkStroke via the adapter on save.
List<PenStroke> _strokes = const [];
/// Snapshot-before-change undo/redo of the stroke list.
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
CanvasTool _tool = CanvasTool.pen;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
String? _noteId;
final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig;
final TransformationController _transform = TransformationController();
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
@override
void initState() {
super.initState();
PenInputService.instance.start();
final note = widget.note;
if (note != null) {
_noteId = note.id;
_titleController.text = note.title;
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else {
_titleController.text = 'Untitled';
}
_initPenConfig();
}
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
if (!mounted) {
controller.dispose();
return;
}
controller.addListener(_onPenConfigChanged);
setState(() {
_penConfig = controller;
_allowFingerDrawing = controller.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_titleController.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
_undo.add(List<PenStroke>.from(_strokes));
_redo.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokes = [..._strokes, stroke];
_dirty = true;
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, index),
...replacements,
..._strokes.sublist(index + 1),
];
_dirty = true;
});
}
void _performUndo() {
if (_undo.isEmpty) return;
setState(() {
_redo.add(List<PenStroke>.from(_strokes));
_strokes = _undo.removeLast();
_dirty = true;
});
}
void _performRedo() {
if (_redo.isEmpty) return;
setState(() {
_undo.add(List<PenStroke>.from(_strokes));
_strokes = _redo.removeLast();
_dirty = true;
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
// ── Persistence ──────────────────────────────────────────────────────────────
/// Convert the live pen strokes back to InkStroke and write the note. Creates
/// the note row on first save. Triggers local OCR for search indexing.
Future<void> _save() async {
if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
final title = _titleController.text.trim().isEmpty
? 'Untitled'
: _titleController.text.trim();
final inkStrokes = <InkStroke>[
for (final s in _strokes)
inkStrokeFromPen(s, kNoteLogicalPage,
id: _uuid.v4(), createdAt: now),
];
Note saved;
if (_noteId == null) {
final created = await notifier.createNote(title: title);
saved = created.copyWith(strokes: inkStrokes, updatedAt: now);
await notifier.updateNote(saved);
_noteId = saved.id;
} else {
saved = (widget.note ?? await _noteById(_noteId!)).copyWith(
title: title,
strokes: inkStrokes,
updatedAt: now,
);
await notifier.updateNote(saved);
}
if (!mounted) return;
setState(() => _dirty = false);
_runLocalOcr(saved);
}
Future<Note> _noteById(String id) async {
final notes = ref.read(noteListProvider).valueOrNull ?? const [];
return notes.firstWhere((n) => n.id == id,
orElse: () => Note(
id: id,
title: _titleController.text,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
));
}
void _runLocalOcr(Note note) {
final id = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.processing,
};
ref.read(ocrServiceProvider).processNote(note).then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.done,
};
}).catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
id: OcrStatus.failed,
};
});
}
// ── Layout helpers ──────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == CanvasTool.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _dirty) _save();
},
child: Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
// Tool palette (top-center) — identical chrome to the PDF editor.
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
// Back (saves on the way out).
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () async {
final navigator = Navigator.of(context);
await _save();
if (mounted) navigator.maybePop();
},
),
),
),
// Title pill (bottom-center).
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildTitlePill(cs),
),
),
),
],
),
),
);
}
Widget _buildCanvas() {
return LayoutBuilder(
builder: (context, constraints) {
// Fit the logical note page into the viewport at scale 1.0.
final fitW = constraints.maxWidth / kNoteLogicalPage.width;
final fitH = constraints.maxHeight / kNoteLogicalPage.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(
kNoteLogicalPage.width * scale,
kNoteLogicalPage.height * scale,
);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
pageSize: pageSize,
strokes: _strokes,
transformationController: _transform,
tool: _tool,
color: _color,
strokeWidth: _strokeWidth,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
sideButtonAction:
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
eraserEndAction:
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
// A white sheet with a soft shadow — the note "paper".
pageWidget: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
spreadRadius: 1,
),
],
),
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ToolButton(
icon: Icons.edit_outlined,
selected: _tool == CanvasTool.pen,
tooltip: 'Pen',
onPressed: () => setState(() => _tool = CanvasTool.pen),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = CanvasTool.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = CanvasTool.eraser),
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undo.isNotEmpty ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _redo.isNotEmpty ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != CanvasTool.eraser;
return GestureDetector(
onTap: () => setState(() {
_color = c;
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildTitlePill(ColorScheme cs) {
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: TextField(
controller: _titleController,
textAlign: TextAlign.center,
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Note title…',
isDense: true,
),
onChanged: (_) => _dirty = true,
),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
// lib/editor/canvas/pen_palette_widgets.dart
//
// Shared Material 3 chrome for the pen-first editors (PDF, note, slide) so the
// floating tool palette looks and behaves identically everywhere — one source
// of truth for the inking UI.
import 'package:flutter/material.dart';
/// A Material 3 toggle-style icon button for the floating tool palette.
class ToolButton extends StatelessWidget {
const ToolButton({
super.key,
required this.icon,
required this.selected,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool selected;
final String tooltip;
/// 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(
borderRadius: BorderRadius.circular(20),
onTap: onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: selected ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Icon(icon, size: 22, color: iconColor),
),
),
);
}
}
/// A thin vertical divider between palette groups.
class PaletteDivider extends StatelessWidget {
const PaletteDivider({super.key, required this.cs});
final ColorScheme cs;
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 24,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: cs.outlineVariant,
);
}
/// A round, tonal icon button (used for the floating back button).
class RoundIconButton extends StatelessWidget {
const RoundIconButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: cs.onSurfaceVariant,
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,96 @@
// lib/editor/notebook/ink_stroke_adapter.dart
//
// Bridge between the legacy note/ppt storage model (`InkStroke`, ABSOLUTE pixel
// coordinates, `PenTool`) and the pen-first canvas model (`PenStroke`,
// NORMALIZED [0,1] coordinates, `PenStrokeKind`). The pen-first canvas is the
// single performant inking engine, so notes and slides are rebuilt on top of it
// and persisted back as `InkStroke` via this adapter.
//
// Coordinates are normalized against a logical page rectangle: ink absolute
// (x,y) -> pen (x/pageW, y/pageH) and back. Stroke width is likewise expressed
// as a fraction of the page width on the pen side and as absolute pixels on the
// ink side. Only freehand pen/highlighter strokes round-trip; shape/text
// `PenTool`s have no pen-canvas representation and are dropped (the pen-first
// note is handwriting-first — see the rebuild roadmap).
import 'dart:ui' show Size;
import '../../models/ink_point.dart';
import '../../models/ink_stroke.dart';
import '../../models/pen_tool.dart';
import '../canvas/pen_stroke.dart';
/// Logical page rectangle a blank note is inked on (portrait, ~A4 √2 ratio).
/// Strokes are normalized against this so they stay pinned under zoom/pan.
const Size kNoteLogicalPage = Size(1000, 1414);
/// True when [tool] is a freehand mark the pen canvas can render
/// (pen/marker/highlighter). Shapes and text are not representable.
bool isFreehandTool(PenTool tool) =>
tool == PenTool.pen ||
tool == PenTool.marker ||
tool == PenTool.highlighter;
/// Maps an ink [PenTool] to the pen-canvas stroke kind.
PenStrokeKind penKindFromTool(PenTool tool) =>
tool == PenTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen;
/// Maps a pen-canvas stroke kind back to a [PenTool].
PenTool toolFromPenKind(PenStrokeKind kind) =>
kind == PenStrokeKind.highlighter ? PenTool.highlighter : PenTool.pen;
/// Convert a stored [InkStroke] (absolute px on [page]) to a [PenStroke]
/// (normalized). Returns null for non-freehand strokes (shapes/text), which the
/// pen canvas cannot draw.
PenStroke? penStrokeFromInk(InkStroke s, Size page) {
if (!isFreehandTool(s.tool)) return null;
if (s.points.isEmpty) return null;
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return PenStroke(
points: [
for (final p in s.points)
PenPoint(p.x / w, p.y / h, p.pressure, tilt: p.tilt),
],
color: s.color,
width: s.strokeWidth / w,
kind: penKindFromTool(s.tool),
);
}
/// Convert a freshly drawn [PenStroke] (normalized) back to an [InkStroke]
/// (absolute px on [page]) for persistence. [id] and [createdAt] come from the
/// caller (uuid + clock) so this stays pure/deterministic.
InkStroke inkStrokeFromPen(
PenStroke s,
Size page, {
required String id,
required DateTime createdAt,
}) {
final w = page.width <= 0 ? 1.0 : page.width;
final h = page.height <= 0 ? 1.0 : page.height;
return InkStroke(
id: id,
points: [
for (final p in s.points)
InkPoint(
x: p.x * w,
y: p.y * h,
pressure: p.pressure ?? 0.5,
tilt: p.tilt ?? 0.0,
timestamp: 0,
),
],
tool: toolFromPenKind(s.kind),
color: s.color,
strokeWidth: s.width * w,
createdAt: createdAt,
);
}
/// Convert a list of stored ink strokes to pen strokes, dropping the ones the
/// canvas cannot represent (shapes/text). Order is preserved.
List<PenStroke> penStrokesFromInk(Iterable<InkStroke> strokes, Size page) =>
[for (final s in strokes) penStrokeFromInk(s, page)]
.whereType<PenStroke>()
.toList();

View File

@@ -7,10 +7,9 @@ import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../editor/canvas/pen_editor_screen.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
import 'ppt_annotator_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
@@ -70,12 +69,6 @@ class HomeScreen extends ConsumerWidget {
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
// New pen-first canvas editor (beta).
IconButton(
icon: const Icon(Icons.draw_outlined),
tooltip: l.penCanvasBeta,
onPressed: () => openM1Spike(context),
),
],
),
floatingActionButton: FloatingActionButton(
@@ -196,7 +189,7 @@ class HomeScreen extends ConsumerWidget {
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
}
}
@@ -375,7 +368,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
);
},
onLongPress: () => _confirmDelete(context),
@@ -425,7 +418,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
if (!mounted) return;
if (result == 'open') {
Navigator.of(this.context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
);
} else if (result == 'delete') {
_confirmDelete(this.context);

View File

@@ -1,303 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
class NoteEditorScreen extends ConsumerStatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
final UndoManager _undoManager = UndoManager();
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
String _title = 'Untitled';
final TextEditingController _titleController = TextEditingController();
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _zoomController = TransformationController();
double _zoomLevel = 1.0;
bool _isDirty = false;
Note? get _existingNote => widget.note;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
@override
void initState() {
super.initState();
if (_existingNote != null) {
_title = _existingNote!.title;
for (final stroke in _existingNote!.strokes) {
_undoManager.addStroke(stroke);
}
}
_titleController.text = _title;
}
@override
void dispose() {
_titleController.dispose();
_zoomController.dispose();
super.dispose();
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_undoManager.addStroke(stroke);
_isDirty = true;
});
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _undoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_isDirty = true;
}
});
}
void _undo() {
setState(() {
_undoManager.undo();
_isDirty = true;
});
}
void _redo() {
setState(() {
_undoManager.redo();
_isDirty = true;
});
}
Future<void> _save() async {
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
Note savedNote;
if (_existingNote != null) {
final updated = _existingNote!.copyWith(
title: _title,
strokes: _undoManager.currentStrokes.toList(),
updatedAt: now,
);
await notifier.updateNote(updated);
savedNote = updated;
} else {
final note = await notifier.createNote(title: _title);
final updated = note.copyWith(
strokes: _undoManager.currentStrokes.toList(),
);
await notifier.updateNote(updated);
savedNote = updated;
}
if (!mounted) return;
setState(() {
_isDirty = false;
});
_runLocalOcr(savedNote);
}
/// Run local OCR and index results for search.
void _runLocalOcr(Note note) {
final noteId = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.processing,
};
ref
.read(ocrServiceProvider)
.processNote(note)
.then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.done,
};
})
.catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.failed,
};
});
}
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomReset() {
_applyZoom(1.0);
}
void _applyZoom(double level) {
setState(() => _zoomLevel = level);
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
}
Future<void> _saveAndNotify() async {
await _save();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Saved')));
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _isDirty) _save();
},
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveAndNotify,
},
child: Focus(
autofocus: true,
child: Scaffold(
appBar: AppBar(
title: SizedBox(
height: 40,
child: TextField(
controller: _titleController,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Note title...',
contentPadding: const EdgeInsets.symmetric(vertical: 8),
suffix: _isDirty
? const Text(
'',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
)
: null,
),
onChanged: (value) {
_title = value;
setState(() => _isDirty = true);
},
),
),
actions: [
IconButton(
icon: const Icon(Icons.check),
tooltip: 'Save',
onPressed: _saveAndNotify,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) =>
setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomReset,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: InteractiveViewer(
transformationController: _zoomController,
minScale: 0.5,
maxScale: 5.0,
child: InkCanvas(
strokes: _undoManager.currentStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -7,7 +7,7 @@ import '../editor/canvas/pen_editor_screen.dart';
import '../l10n/app_localizations.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import '../editor/canvas/pen_note_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@@ -179,7 +179,7 @@ class _NoteSearchResultTile extends StatelessWidget {
onTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
},
);
}

View File

@@ -0,0 +1,98 @@
// Proves the InkStroke<->PenStroke bridge used to host notes/slides on the
// pen-first canvas: normalization round-trips within the logical page, the
// tool<->kind mapping is correct, and non-freehand strokes (shapes/text) are
// dropped because the pen canvas cannot render them.
import 'dart:ui' show Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/canvas/pen_stroke.dart';
import 'package:badnote/editor/notebook/ink_stroke_adapter.dart';
import 'package:badnote/models/ink_point.dart';
import 'package:badnote/models/ink_stroke.dart';
import 'package:badnote/models/pen_tool.dart';
void main() {
const page = Size(1000, 1414);
final t0 = DateTime.fromMillisecondsSinceEpoch(0);
InkStroke ink(
List<InkPoint> pts, {
PenTool tool = PenTool.pen,
double width = 2.0,
int color = 0xFF112233,
}) =>
InkStroke(
id: 'a',
points: pts,
tool: tool,
color: color,
strokeWidth: width,
createdAt: t0,
);
InkPoint ip(double x, double y, {double pressure = 0.7}) =>
InkPoint(x: x, y: y, pressure: pressure, timestamp: 0);
test('ink -> pen normalizes against the page', () {
final pen = penStrokeFromInk(
ink([ip(250, 707), ip(500, 1414)], width: 10), page)!;
expect(pen.points.first.x, closeTo(0.25, 1e-9));
expect(pen.points.first.y, closeTo(0.5, 1e-9));
expect(pen.points[1].x, closeTo(0.5, 1e-9));
expect(pen.points[1].y, closeTo(1.0, 1e-9));
expect(pen.width, closeTo(10 / 1000, 1e-9));
expect(pen.color, 0xFF112233);
expect(pen.kind, PenStrokeKind.pen);
});
test('round-trip ink -> pen -> ink preserves coords, width, color', () {
final original = ink([ip(123, 456), ip(789, 1000)], width: 7, color: 0xFFABCDEF);
final pen = penStrokeFromInk(original, page)!;
final back = inkStrokeFromPen(pen, page, id: 'b', createdAt: t0);
for (var i = 0; i < original.points.length; i++) {
expect(back.points[i].x, closeTo(original.points[i].x, 1e-6));
expect(back.points[i].y, closeTo(original.points[i].y, 1e-6));
}
expect(back.strokeWidth, closeTo(7, 1e-6));
expect(back.color, 0xFFABCDEF);
expect(back.tool, PenTool.pen);
});
test('highlighter maps to the highlighter kind both ways', () {
final pen = penStrokeFromInk(
ink([ip(10, 10), ip(20, 20)], tool: PenTool.highlighter), page)!;
expect(pen.kind, PenStrokeKind.highlighter);
final back = inkStrokeFromPen(pen, page, id: 'c', createdAt: t0);
expect(back.tool, PenTool.highlighter);
});
test('non-freehand strokes (shapes/text) are dropped', () {
for (final tool in [
PenTool.rectangle,
PenTool.ellipse,
PenTool.line,
PenTool.arrow,
PenTool.text,
]) {
expect(penStrokeFromInk(ink([ip(0, 0), ip(5, 5)], tool: tool), page),
isNull,
reason: '$tool has no pen-canvas representation');
}
});
test('penStrokesFromInk preserves order and filters non-freehand', () {
final strokes = [
ink([ip(0, 0), ip(1, 1)], tool: PenTool.pen, color: 0xFF000001),
ink([ip(2, 2), ip(3, 3)], tool: PenTool.rectangle),
ink([ip(4, 4), ip(5, 5)], tool: PenTool.highlighter, color: 0xFF000002),
];
final pens = penStrokesFromInk(strokes, page);
expect(pens, hasLength(2));
expect(pens[0].color, 0xFF000001);
expect(pens[1].color, 0xFF000002);
expect(pens[1].kind, PenStrokeKind.highlighter);
});
}

View File

@@ -0,0 +1,76 @@
// Screen-level guard for the pen-first note editor: an existing note's ink
// strokes load onto the PenCanvas via the adapter, and a stylus pass commits a
// new stroke. Exercises the real PenNoteScreen (no DB needed — persistence only
// runs on save).
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/canvas/pen_canvas.dart';
import 'package:badnote/editor/canvas/pen_note_screen.dart';
import 'package:badnote/models/ink_point.dart';
import 'package:badnote/models/ink_stroke.dart';
import 'package:badnote/models/note.dart';
import 'package:badnote/models/pen_tool.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Note noteWith(List<InkStroke> strokes) => Note(
id: 'n1',
title: 'Test',
strokes: strokes,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
InkStroke freehand() => InkStroke(
id: 's1',
points: const [
InkPoint(x: 100, y: 200, timestamp: 0),
InkPoint(x: 300, y: 400, timestamp: 0),
InkPoint(x: 500, y: 700, timestamp: 0),
],
tool: PenTool.pen,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
int canvasStrokeCount(WidgetTester tester) =>
tester.widget<PenCanvas>(find.byType(PenCanvas)).strokes.length;
testWidgets('loads an existing note\'s ink strokes onto the canvas',
(tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(ProviderScope(
child: MaterialApp(home: PenNoteScreen(note: noteWith([freehand()]))),
));
await tester.pump(); // let PenConfig load
expect(find.byType(PenCanvas), findsOneWidget);
expect(canvasStrokeCount(tester), 1,
reason: 'the note\'s freehand stroke should load via the adapter');
});
testWidgets('a stylus pass commits a new stroke', (tester) async {
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(ProviderScope(
child: MaterialApp(home: PenNoteScreen(note: noteWith([]))),
));
await tester.pump();
expect(canvasStrokeCount(tester), 0);
final center = tester.getCenter(find.byType(PenCanvas));
final g = await tester.startGesture(center, kind: PointerDeviceKind.stylus);
await g.moveBy(const Offset(40, 30));
await g.moveBy(const Offset(30, 20));
await g.up();
await tester.pump();
expect(canvasStrokeCount(tester), 1,
reason: 'the drawn stroke should be committed to the canvas');
});
}