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 '../ui/thumbnail_grid.dart';
|
||||||
import 'input_diagnostics.dart';
|
import 'input_diagnostics.dart';
|
||||||
import 'pen_canvas.dart';
|
import 'pen_canvas.dart';
|
||||||
|
import 'pen_palette_widgets.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
||||||
@@ -444,7 +445,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
SafeArea(
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: _RoundIconButton(
|
child: RoundIconButton(
|
||||||
icon: Icons.arrow_back,
|
icon: Icons.arrow_back,
|
||||||
tooltip: l.back,
|
tooltip: l.back,
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
@@ -620,42 +621,42 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.edit_outlined,
|
icon: Icons.edit_outlined,
|
||||||
selected: _tool == CanvasTool.pen,
|
selected: _tool == CanvasTool.pen,
|
||||||
tooltip: l.toolPen,
|
tooltip: l.toolPen,
|
||||||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||||||
),
|
),
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
selected: _tool == CanvasTool.highlighter,
|
selected: _tool == CanvasTool.highlighter,
|
||||||
tooltip: l.toolHighlighter,
|
tooltip: l.toolHighlighter,
|
||||||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||||
),
|
),
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.cleaning_services_outlined,
|
icon: Icons.cleaning_services_outlined,
|
||||||
selected: _tool == CanvasTool.eraser,
|
selected: _tool == CanvasTool.eraser,
|
||||||
tooltip: l.toolEraser,
|
tooltip: l.toolEraser,
|
||||||
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||||||
),
|
),
|
||||||
_Divider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
// Undo / redo (per page).
|
// Undo / redo (per page).
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.undo,
|
icon: Icons.undo,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: l.actionUndo,
|
tooltip: l.actionUndo,
|
||||||
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
||||||
),
|
),
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.redo,
|
icon: Icons.redo,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: l.actionRedo,
|
tooltip: l.actionRedo,
|
||||||
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
|
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
|
||||||
),
|
),
|
||||||
_Divider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
for (final c in _palette) _colorDot(c, cs),
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
_Divider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
selected: _allowFingerDrawing,
|
selected: _allowFingerDrawing,
|
||||||
tooltip: _allowFingerDrawing
|
tooltip: _allowFingerDrawing
|
||||||
@@ -664,20 +665,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onPressed: _toggleFingerDrawing,
|
onPressed: _toggleFingerDrawing,
|
||||||
),
|
),
|
||||||
// Page thumbnail grid.
|
// Page thumbnail grid.
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.grid_view,
|
icon: Icons.grid_view,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: l.pages,
|
tooltip: l.pages,
|
||||||
onPressed: _document != null ? _openThumbnails : null,
|
onPressed: _document != null ? _openThumbnails : null,
|
||||||
),
|
),
|
||||||
// Pen settings.
|
// Pen settings.
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.settings_outlined,
|
icon: Icons.settings_outlined,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: l.penSettings,
|
tooltip: l.penSettings,
|
||||||
onPressed: _penConfig != null ? _openPenSettings : null,
|
onPressed: _penConfig != null ? _openPenSettings : null,
|
||||||
),
|
),
|
||||||
_ToolButton(
|
ToolButton(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
selected: _showPenDebug,
|
selected: _showPenDebug,
|
||||||
tooltip: l.inputDiagnostic,
|
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/note_provider.dart';
|
||||||
import '../providers/ocr_provider.dart';
|
import '../providers/ocr_provider.dart';
|
||||||
import '../editor/canvas/pen_editor_screen.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
import '../editor/pdf/spike_launcher.dart';
|
|
||||||
import '../services/pdf_service.dart';
|
import '../services/pdf_service.dart';
|
||||||
import '../services/pptx_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 'ppt_annotator_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
@@ -70,12 +69,6 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
|
).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(
|
floatingActionButton: FloatingActionButton(
|
||||||
@@ -196,7 +189,7 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
|
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +368,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onLongPress: () => _confirmDelete(context),
|
onLongPress: () => _confirmDelete(context),
|
||||||
@@ -425,7 +418,7 @@ class _NoteTileState extends ConsumerState<_NoteTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result == 'open') {
|
if (result == 'open') {
|
||||||
Navigator.of(this.context).push(
|
Navigator.of(this.context).push(
|
||||||
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: widget.note)),
|
||||||
);
|
);
|
||||||
} else if (result == 'delete') {
|
} else if (result == 'delete') {
|
||||||
_confirmDelete(this.context);
|
_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 '../l10n/app_localizations.dart';
|
||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../providers/search_provider.dart';
|
import '../providers/search_provider.dart';
|
||||||
import 'note_editor_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
|
|
||||||
class SearchScreen extends ConsumerStatefulWidget {
|
class SearchScreen extends ConsumerStatefulWidget {
|
||||||
const SearchScreen({super.key});
|
const SearchScreen({super.key});
|
||||||
@@ -179,7 +179,7 @@ class _NoteSearchResultTile extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(
|
Navigator.of(
|
||||||
context,
|
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