feat(note): rebuild note editor on the pen-first canvas
Some checks failed
CI / Windows build (push) Has been cancelled
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:
33
.claude/pua-loop-context.md
Normal file
33
.claude/pua-loop-context.md
Normal 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.
|
||||
3
.claude/pua-loop-history.jsonl
Normal file
3
.claude/pua-loop-history.jsonl
Normal 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
7888
badnote_input_log-2.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
459
lib/editor/canvas/pen_note_screen.dart
Normal file
459
lib/editor/canvas/pen_note_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/editor/canvas/pen_palette_widgets.dart
Normal file
96
lib/editor/canvas/pen_palette_widgets.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/editor/notebook/ink_stroke_adapter.dart
Normal file
96
lib/editor/notebook/ink_stroke_adapter.dart
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
98
test/ink_stroke_adapter_test.dart
Normal file
98
test/ink_stroke_adapter_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
76
test/pen_note_screen_widget_test.dart
Normal file
76
test/pen_note_screen_widget_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user