// 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 '../engine/brush.dart'; import '../engine/shape_geometry.dart'; import '../engine/stroke_model.dart'; import '../persistence/sidecar_repository.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 '../ui/pen_settings_page.dart'; import 'editor_tool.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 createState() => _PenNoteScreenState(); } class _PenNoteScreenState extends ConsumerState { 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 _strokes = const []; /// Snapshot-before-change undo/redo of the stroke list. final List> _undo = []; final List> _redo = []; /// The single active-tool state (shared model across the 3 editors). EditorToolKind _tool = EditorToolKind.brush; /// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The /// highlighter tool always uses [BrushKind.highlighter]; local state only for /// this increment (not persisted — see TODO(brush-persist-selection)). BrushKind _penBrush = BrushKind.fountainPen; /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; /// Index of the currently selected committed stroke (SELECT tool), or null. int? _selectedStroke; /// rnote-style per-brush color memory: each brush (and the highlighter) /// remembers its own color. Selecting a brush restores its color; picking a /// color updates ONLY the active brush's entry. In-memory only for this /// increment (TODO(brush-color-persist)). final Map _brushColors = { BrushKind.fountainPen: Colors.black, BrushKind.ballpoint: Colors.blue, BrushKind.pencil: Colors.green, BrushKind.highlighter: Colors.orange, }; /// The brush whose color the color-dots edit: the highlighter when the /// highlighter tool is active, otherwise the selected pen brush. BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter ? BrushKind.highlighter : _penBrush; /// The active drawing color (the active brush's remembered color). Color get _color => _brushColors[_activeColorBrush] ?? Colors.black; bool _allowFingerDrawing = false; bool _dirty = false; bool _needsCenter = true; /// The note's synthetic source path `/notebook` (also the note id). /// Persistence flows through this note's `notebook.badnote.json` sidecar. String? _notePath; /// Per-file sidecar persistence sink (strokes page 0 + title), debounced and /// atomic — replaces the old SQLite Note/noteListProvider write path here. SidecarRepository? _repo; /// Page index a standalone note's strokes live under in the sidecar. static const int _notePageIndex = 0; 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 _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) { _notePath = note.id; _titleController.text = note.title; // Seed from the in-memory note's strokes (e.g. tests) until the sidecar // load resolves and (if present) overrides with persisted strokes. _strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage); } else { _titleController.text = 'Untitled'; } _initPenConfig(); if (_notePath != null) _initPersistence(_notePath!); } /// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted /// strokes / a title, hydrate the canvas from them. Strokes load as page-0 /// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor). Future _initPersistence(String notePath) async { final repo = await SidecarRepository.open(notePath, docType: 'notebook'); if (!mounted) { repo.dispose(); return; } _repo = repo; final loaded = repo.loadedStrokes[_notePageIndex]; setState(() { if (loaded != null && loaded.isNotEmpty) { _strokes = [for (final es in loaded) _penStrokeFromEditor(es)]; } final title = repo.loadedTitle; if (title != null && title.isNotEmpty) { _titleController.text = title; } }); } /// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush /// is persisted on the EditorStroke now, so carry it through; old sidecars /// without the field decode to fountainPen (back-compat default). PenStroke _penStrokeFromEditor(EditorStroke es) => PenStroke( points: es.points .map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt)) .toList(), color: es.color, width: es.width, kind: es.tool == EditorTool.highlighter ? PenStrokeKind.highlighter : PenStrokeKind.pen, brush: es.brush, ); Future _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() { // Flush any pending sidecar write before tearing down (atomic write // completes off the widget tree). final repo = _repo; if (repo != null) { repo.flush(); repo.dispose(); } _penConfig?.removeListener(_onPenConfigChanged); _penConfig?.dispose(); _titleController.dispose(); _transform.dispose(); super.dispose(); } // ── Mutations ────────────────────────────────────────────────────────────── void _pushUndo() { _undo.add(List.from(_strokes)); _redo.clear(); } void _commitStroke(PenStroke stroke) { setState(() { _pushUndo(); _strokes = [..._strokes, stroke]; _dirty = true; }); } void _eraseStroke(int index, List 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.from(_strokes)); _strokes = _undo.removeLast(); _dirty = true; }); } void _performRedo() { if (_redo.isEmpty) return; setState(() { _undo.add(List.from(_strokes)); _strokes = _redo.removeLast(); _dirty = true; }); } void _toggleFingerDrawing() { final next = !_allowFingerDrawing; setState(() => _allowFingerDrawing = next); _penConfig?.setFingerDrawing(next); } // ── Persistence ────────────────────────────────────────────────────────────── /// Persist the live pen strokes + title to the note's `notebook.badnote.json` /// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title /// field), debounced/atomic via [SidecarRepository]. Creates the notebook /// folder lazily on first save when the screen was opened without a path. /// Refreshes the home list and triggers local OCR for search indexing. Future _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(); // Lazily create the notebook folder + sidecar repo on first save. if (_repo == null) { final created = await notifier.createNote(title: title); if (!mounted) return; _notePath = created.id; final repo = await SidecarRepository.open(created.id, docType: 'notebook'); if (!mounted) { repo.dispose(); return; } _repo = repo; } final repo = _repo!; final editorStrokes = [ for (final s in _strokes) EditorStroke.fromPenStroke(s), ]; repo.scheduleTitleSave(title); repo.scheduleStrokeSave(_notePageIndex, editorStrokes); await repo.flush(); // Refresh the home list so the title/recency update is visible on return. await notifier.loadNotes(); if (!mounted) return; setState(() => _dirty = false); // Build an in-memory Note (id = note path) for OCR/FTS indexing only. final inkStrokes = [ for (final s in _strokes) inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now), ]; _runLocalOcr(Note( id: _notePath!, title: title, strokes: inkStrokes, createdAt: now, updatedAt: 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 == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) : (_penConfig?.value.penWidth ?? _penWidthFraction); // ── SELECT tool: select / move / delete (reuses the undo stacks) ───────────── /// Set (or clear) the selected stroke from a SELECT-tool tap. void _selectStroke(int? index) { setState(() => _selectedStroke = index); } /// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta /// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a /// single undo step. void _moveStroke(int index, double dx, double dy, bool isDragStart) { if (index < 0 || index >= _strokes.length) return; setState(() { if (isDragStart) _pushUndo(); final next = List.from(_strokes); next[index] = translateStroke(next[index], dx, dy); _strokes = next; _dirty = true; }); } /// Delete the selected stroke (button or long-press), as one undo step. void _deleteSelected() { final idx = _selectedStroke; if (idx == null || idx < 0 || idx >= _strokes.length) return; setState(() { _pushUndo(); _strokes = [ ..._strokes.sublist(0, idx), ..._strokes.sublist(idx + 1), ]; _selectedStroke = null; _dirty = true; }); } @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: editorToolToCanvas(_tool), brush: _penBrush, shapeKind: _shapeKind, color: _color, strokeWidth: _strokeWidth, selectedStrokeIndex: _selectedStroke, onSelectStroke: _selectStroke, onMoveStroke: _moveStroke, 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: [ // Pen tool with brush picker (fountain / ballpoint / pencil), each // brush showing its own remembered color. BrushPickerButton( selected: _penBrush, active: _tool == EditorToolKind.brush, tooltip: 'Brush', labelFor: brushLabelEn, colorFor: (b) => _brushColors[b] ?? Colors.black, onSelected: (b) => setState(() { _penBrush = b; _tool = EditorToolKind.brush; }), ), ToolButton( icon: Icons.brush_outlined, selected: _tool == EditorToolKind.highlighter, tooltip: 'Highlighter', onPressed: () => setState(() => _tool = EditorToolKind.highlighter), ), ToolButton( icon: Icons.cleaning_services_outlined, selected: _tool == EditorToolKind.eraser, tooltip: 'Eraser', onPressed: () => setState(() => _tool = EditorToolKind.eraser), ), // Select (cursor) + shape tools. ToolButton( icon: Icons.ads_click, selected: _tool == EditorToolKind.select, tooltip: 'Select', onPressed: () => setState(() => _tool = EditorToolKind.select), ), ShapePickerButton( selected: _shapeKind, active: _tool == EditorToolKind.shape, tooltip: 'Shape', labelFor: shapeLabelEn, onActivate: () => setState(() => _tool = EditorToolKind.shape), onSelected: (s) => setState(() { _shapeKind = s; _tool = EditorToolKind.shape; }), ), if (_tool == EditorToolKind.select && _selectedStroke != null) ToolButton( icon: Icons.delete_outline, selected: false, tooltip: 'Delete selection', onPressed: _deleteSelected, ), 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, ), ToolButton( icon: Icons.settings_outlined, selected: false, tooltip: 'Pen settings (width, pressure, eraser…)', onPressed: _penConfig != null ? () => showPenSettingsSheet(context, _penConfig!) : null, ), ], ), ), ); } Widget _colorDot(Color c, ColorScheme cs) { // Selected against the ACTIVE brush's remembered color. A color tap updates // only that brush's entry (rnote per-brush color memory). final selected = _color.toARGB32() == c.toARGB32() && _tool != EditorToolKind.eraser && _tool != EditorToolKind.select; return GestureDetector( onTap: () => setState(() { if (_tool == EditorToolKind.eraser || _tool == EditorToolKind.select) { _tool = EditorToolKind.brush; } _brushColors[_activeColorBrush] = c; }), 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, ), ), ), ); } }