// 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 '../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 '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 = []; CanvasTool _tool = CanvasTool.pen; /// Selected brush for the PEN 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; 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 _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 _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.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 ────────────────────────────────────────────────────────────── /// 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 _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 = [ 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 _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, brush: _penBrush, 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: [ // Pen tool with brush picker (fountain / ballpoint / pencil). BrushPickerButton( selected: _penBrush, active: _tool == CanvasTool.pen, tooltip: 'Brush', labelFor: brushLabelEn, onSelected: (b) => setState(() { _penBrush = b; _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, ), 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) { 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, ), ), ), ); } }