// lib/editor/canvas/pen_editor_screen.dart // // Page-based pen-first PDF editor. Opens a PDF with pdfrx's document API, // shows ONE page at a time as a bitmap (PdfPageView — a per-page widget that // renders to an image and does NOT capture pan/zoom gestures), overlaid by the // ink layer. Both share one transform via PenCanvas. Prev/Next + jump-to-page. import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; import '../../services/database_service.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; import '../engine/undo_stack.dart'; import '../input/diagnostic_logger.dart'; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import '../persistence/editor_repository.dart'; import '../persistence/save_scheduler.dart'; import '../ui/pen_settings_page.dart'; import '../ui/thumbnail_grid.dart'; import 'input_diagnostics.dart'; import 'pen_canvas.dart'; import 'pen_stroke.dart'; /// Stable deterministic document-id for a file path (djb2 hash → hex). /// /// Produces a fixed-length hex string from the path so the id is filesystem- /// independent (no slashes, spaces, or non-ASCII characters) and stable across /// restarts. Collisions are astronomically unlikely for a single-user app. String _documentIdFromPath(String path) { var hash = 5381; for (final c in path.codeUnits) { hash = ((hash << 5) + hash + c) & 0xFFFFFFFF; } return hash.toRadixString(16).padLeft(8, '0'); } class PenEditorScreen extends StatefulWidget { const PenEditorScreen({super.key, required this.pdfPath}); final String pdfPath; @override State createState() => _PenEditorScreenState(); } class _PenEditorScreenState extends State { PdfDocument? _document; Object? _openError; /// 0-based current page index. int _pageIndex = 0; /// Strokes per page, keyed by 0-based page index (normalized coords). final Map> _strokesByPage = {}; /// Per-page undo/redo history. Snapshot-before-change discipline: the /// pre-mutation stroke list is recorded before each commit/erase. final Map>> _undo = {}; UndoStack> _undoFor(int page) => _undo.putIfAbsent(page, () => UndoStack>()); /// Pen input configuration (widths, finger drawing, button actions). /// Loaded asynchronously in initState; null until ready. /// /// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd) /// are persisted via this controller but NOT yet consumed here — they wire /// into the input arbiter in a later step. Only widths and fingerDrawing are /// consumed for now. PenConfigController? _penConfig; // ── Persistence ──────────────────────────────────────────────────────────── /// Stable document-id derived from the PDF file path. late final String _documentId; SaveScheduler? _saveScheduler; /// One shared transform for the current page; recentred on page change so /// each page opens fit-to-view and centered. final TransformationController _transform = TransformationController(); /// Set when the page must be (re)centered on the next layout pass. bool _needsCenter = true; /// Live page value while dragging the page slider (null when not dragging). double? _scrub; /// Whether the page-jump slider is expanded (NOT persistent — toggled by /// tapping the page label; collapses after a jump). bool _showSlider = false; /// Latest pen-event debug readout (kind/pressure/min/max) — shown only when /// the diagnostic toggle is on, to inspect what Windows delivers. String _penDebug = ''; bool _showPenDebug = false; // Tool state. CanvasTool _tool = CanvasTool.pen; Color _color = Colors.black; bool _allowFingerDrawing = false; /// Pen width as a fraction of page width (base; pressure thins it down). 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(); _documentId = _documentIdFromPath(widget.pdfPath); // Begin listening to the native Windows pen plugin (barrel/eraser/tilt). // No-op on platforms without the plugin (W3). PenInputService.instance.start(); _initPersistence(); _initPenConfig(); _open(); } Future _initPenConfig() async { final controller = await PenConfigController.load(); if (!mounted) { controller.dispose(); return; } // Rebuild the editor when pen settings change (width, pressure // sensitivity, button mappings) so the live canvas reflects them. controller.addListener(_onPenConfigChanged); setState(() { _penConfig = controller; // Adopt the persisted finger-drawing preference as the initial local // toggle state. The local 🖐 toggle keeps working and stays in sync with // the controller (see _toggleFingerDrawing). _allowFingerDrawing = controller.value.fingerDrawing; }); } void _onPenConfigChanged() { if (mounted) setState(() {}); } Future _initPersistence() async { final service = await DatabaseService.getInstance(); if (!mounted) return; final repo = await EditorRepository.fromService(service); final scheduler = SaveScheduler(repo); if (!mounted) { scheduler.dispose(); return; } _saveScheduler = scheduler; // Load any previously persisted strokes for this document. await _loadPersistedStrokes(repo); } /// Load all persisted strokes for [_documentId] and populate [_strokesByPage]. Future _loadPersistedStrokes(EditorRepository repo) async { final hosted = await repo.loadDocument(_documentId); if (!mounted) return; final loaded = >{}; for (final entry in hosted.entries) { final pageIndex = _pageIndexFromHostId(entry.key); if (pageIndex == null) continue; loaded[pageIndex] = entry.value .map((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, )) .toList(); } if (loaded.isNotEmpty) { setState(() { for (final entry in loaded.entries) { _strokesByPage[entry.key] = entry.value; } }); } } /// Extract the page index from a host_id of the form /// `"doc::page:"`. int? _pageIndexFromHostId(String hostId) { const marker = ':page:'; final idx = hostId.lastIndexOf(marker); if (idx == -1) return null; return int.tryParse(hostId.substring(idx + marker.length)); } Future _open() async { try { final doc = await PdfDocument.openFile(widget.pdfPath); if (!mounted) { doc.dispose(); return; } setState(() => _document = doc); } catch (e) { if (mounted) setState(() => _openError = e); } } @override void dispose() { // Flush any pending scheduled saves before tearing down. final scheduler = _saveScheduler; if (scheduler != null) { scheduler.flush(); // fire-and-forget; DB write continues in isolate scheduler.dispose(); } _document?.dispose(); _transform.dispose(); _penConfig?.dispose(); PenInputService.instance.stop(); DiagnosticLogger.instance.stop(); super.dispose(); } List get _currentStrokes => _strokesByPage.putIfAbsent(_pageIndex, () => []); void _commitStroke(PenStroke stroke) { // Snapshot-before-change: record the pre-mutation page state for undo. _undoFor(_pageIndex).record(List.of(_currentStrokes)); setState(() { // Replace with a NEW list so StaticInkPainter sees a fresh identity and // actually repaints (mutating in place would alias the old painter's list // and shouldRepaint would see no change → committed strokes vanish). _strokesByPage[_pageIndex] = [ ...?_strokesByPage[_pageIndex], stroke, ]; }); // Snapshot SYNCHRONOUSLY (before any await) then schedule persistence. final snapshot = List.of(_strokesByPage[_pageIndex]!); _schedulePageSave(_pageIndex, snapshot); } /// Replace committed stroke [index] with its surviving pieces after a partial /// (segment) erase. An empty [replacements] list removes the stroke entirely. void _eraseStroke(int index, List replacements) { final list = _strokesByPage[_pageIndex]; final willMutate = list != null && index >= 0 && index < list.length; if (willMutate) { // Snapshot-before-change: record the pre-mutation page state for undo. _undoFor(_pageIndex).record(List.of(list)); } setState(() { if (list != null && index >= 0 && index < list.length) { final next = List.of(list) ..replaceRange(index, index + 1, replacements); _strokesByPage[_pageIndex] = next; } }); // Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence. final current = _strokesByPage[_pageIndex]; final snapshot = current != null ? List.of(current) : []; _schedulePageSave(_pageIndex, snapshot); } /// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler. /// /// Must be called synchronously (no await between the snapshot and this call) /// so the scheduler receives an immutable copy of the in-memory state. void _schedulePageSave(int pageIndex, List strokes) { final scheduler = _saveScheduler; if (scheduler == null) return; final editorStrokes = strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(); scheduler.schedule( 'page', EditorRepository.pageHostId(_documentId, pageIndex), editorStrokes, ); } void _goToPage(int index) { final doc = _document; if (doc == null) return; final clamped = index.clamp(0, doc.pages.length - 1); if (clamped == _pageIndex) return; setState(() { _pageIndex = clamped; _needsCenter = true; // recenter the new page on next layout }); } /// Undo the last draw/erase on the current page, restoring and persisting /// the previous snapshot. void _performUndo() { final stack = _undoFor(_pageIndex); if (!stack.canUndo) return; final current = List.of(_currentStrokes); final snapshot = stack.undo(current); if (snapshot == null) return; setState(() { // New list identity so StaticInkPainter repaints. _strokesByPage[_pageIndex] = List.of(snapshot); }); _schedulePageSave(_pageIndex, List.of(snapshot)); } /// Redo the last undone draw/erase on the current page. void _performRedo() { final stack = _undoFor(_pageIndex); if (!stack.canRedo) return; final snapshot = stack.redo(); if (snapshot == null) return; setState(() { _strokesByPage[_pageIndex] = List.of(snapshot); }); _schedulePageSave(_pageIndex, List.of(snapshot)); } /// Cycle pen → highlighter → eraser → pen (for the toggleTool button action). void _cycleTool() { setState(() { _tool = switch (_tool) { CanvasTool.pen => CanvasTool.highlighter, CanvasTool.highlighter => CanvasTool.eraser, CanvasTool.eraser => CanvasTool.pen, }; }); } /// Handle a hardware pen-button action delivered by [PenCanvas] (W3). /// `eraser` and `pan` are handled inside the canvas; here we map the /// edge-triggered ones. void _handlePenButtonAction(PenButtonAction action) { switch (action) { case PenButtonAction.undo: _performUndo(); case PenButtonAction.toggleTool: _cycleTool(); case PenButtonAction.eraser: case PenButtonAction.pan: case PenButtonAction.none: break; } } /// Toggle finger-drawing, keeping the local state and the persisted config /// (when loaded) in sync. void _toggleFingerDrawing() { final next = !_allowFingerDrawing; setState(() => _allowFingerDrawing = next); _penConfig?.setFingerDrawing(next); } /// Open the page thumbnail grid; tapping a thumbnail navigates to that page. void _openThumbnails() { final doc = _document; if (doc == null) return; showPageThumbnailSheet( context, document: doc, currentPage: _pageIndex, onPageSelected: _goToPage, ); } /// Open the pen settings sheet (widths, pressure, finger drawing, etc.). void _openPenSettings() { final config = _penConfig; if (config == null) return; showPenSettingsSheet(context, config); } /// Centre [pageSize] within [viewport] via the shared transform. void _centerPage(Size viewport, Size pageSize) { final tx = (viewport.width - pageSize.width) / 2; final ty = (viewport.height - pageSize.height) / 2; _transform.value = Matrix4.identity()..setTranslationRaw(tx, ty, 0); } @override Widget build(BuildContext context) { return Scaffold( body: Stack( children: [ Positioned.fill(child: _buildBody()), // Floating Material You tool palette (top-center). SafeArea( child: Align( alignment: Alignment.topCenter, child: Padding( padding: const EdgeInsets.only(top: 8), child: _buildToolPalette(), ), ), ), // Floating page-control pill (bottom-center). if (_document != null) SafeArea( child: Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 16), child: _buildPagePill(), ), ), ), // Back button (top-left). SafeArea( child: Padding( padding: const EdgeInsets.all(8), child: _RoundIconButton( icon: Icons.arrow_back, tooltip: 'Back', onPressed: () => Navigator.of(context).maybePop(), ), ), ), // Pen diagnostic readout (top-right) — shows what Windows delivers. if (_showPenDebug) SafeArea( child: Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(8), child: Material( color: Theme.of(context).colorScheme.inverseSurface, borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 380), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), child: ListenableBuilder( listenable: InputDiagnostics.instance, builder: (context, _) { final cs = Theme.of(context).colorScheme; final d = InputDiagnostics.instance; final tail = d.trace.length > 6 ? d.trace.sublist(d.trace.length - 6) : d.trace; final mono = TextStyle( fontFamily: 'monospace', fontSize: 11, color: cs.onInverseSurface); final monoFaint = mono.copyWith( fontSize: 10, color: cs.onInverseSurface.withValues(alpha: 0.75)); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( _penDebug.isEmpty ? 'hover / draw with the pen…' : _penDebug, style: mono), const SizedBox(height: 4), Text(d.summary(), style: mono), if (tail.isNotEmpty) ...[ const SizedBox(height: 4), Text(tail.join('\n'), style: monoFaint), ], const SizedBox(height: 4), Text( 'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}', style: monoFaint), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => InputDiagnostics.instance.reset(), child: Text('Reset stats', style: TextStyle(color: cs.inversePrimary)), ), ), ], ); }, ), ), ), ), ), ), ), ], ), ); } Widget _buildBody() { if (_openError != null) { return Center(child: Text('Failed to open PDF:\n$_openError')); } final doc = _document; if (doc == null) { return const Center(child: CircularProgressIndicator()); } if (doc.pages.isEmpty) { return const Center(child: Text('PDF has no pages.')); } final page = doc.pages[_pageIndex]; return LayoutBuilder( builder: (context, constraints) { // Fit the page rectangle into the available viewport at scale 1.0; the // InteractiveViewer then zooms/pans from there. Ink normalized coords // map onto this rectangle. final fit = (constraints.maxWidth / page.width) .clamp(0.0, double.infinity); final fitH = constraints.maxHeight / page.height; final scale = fit < fitH ? fit : fitH; final pageSize = Size(page.width * scale, page.height * scale); if (_needsCenter) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _centerPage( Size(constraints.maxWidth, constraints.maxHeight), pageSize); setState(() => _needsCenter = false); }); } return PenCanvas( key: ValueKey(_pageIndex), pageSize: pageSize, strokes: _currentStrokes, transformationController: _transform, tool: _tool, color: _color, strokeWidth: _tool == CanvasTool.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) : (_penConfig?.value.penWidth ?? _penWidthFraction), thinning: _penConfig?.value.pressureSensitivity ?? kDefaultPenThinning, sideButtonAction: _penConfig?.value.sideButton ?? PenButtonAction.eraser, eraserEndAction: _penConfig?.value.eraserEnd ?? PenButtonAction.eraser, onPenButtonAction: _handlePenButtonAction, allowFingerDrawing: _allowFingerDrawing, onPenDebug: _showPenDebug ? (s) => setState(() => _penDebug = s) : null, onStrokeComplete: _commitStroke, onEraseStroke: _eraseStroke, pageWidget: PdfPageView( document: doc, pageNumber: _pageIndex + 1, // Fill the SizedBox exactly so ink aligns to the page rect (no // internal letterboxing offset). pageSizeCallback: (biggest, page, rotation) => biggest, decoration: const BoxDecoration(color: Colors.white), backgroundColor: Colors.white, ), ); }, ); } /// Floating Material You tool palette: a tonal rounded surface holding the /// tools, color dots, and finger-drawing toggle. Widget _buildToolPalette() { final cs = Theme.of(context).colorScheme; 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), ), _Divider(cs: cs), // Undo / redo (per page). _ToolButton( icon: Icons.undo, selected: false, tooltip: 'Undo', onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null, ), _ToolButton( icon: Icons.redo, selected: false, tooltip: 'Redo', onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null, ), _Divider(cs: cs), for (final c in _palette) _colorDot(c, cs), _Divider(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, ), // Page thumbnail grid. _ToolButton( icon: Icons.grid_view, selected: false, tooltip: 'Pages', onPressed: _document != null ? _openThumbnails : null, ), // Pen settings. _ToolButton( icon: Icons.settings_outlined, selected: false, tooltip: 'Pen settings', onPressed: _penConfig != null ? _openPenSettings : null, ), _ToolButton( icon: Icons.bug_report_outlined, selected: _showPenDebug, tooltip: 'Input diagnostic (writes a log file)', onPressed: () { final on = !_showPenDebug; setState(() => _showPenDebug = on); if (on) { InputDiagnostics.instance.reset(); DiagnosticLogger.instance.start(); } else { DiagnosticLogger.instance.stop(); } }, ), ], ), ), ); } Widget _colorDot(Color c, ColorScheme cs) { final selected = _color == c; return GestureDetector( onTap: () => setState(() => _color = c), child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 28, height: 28, margin: const EdgeInsets.symmetric(horizontal: 3), decoration: BoxDecoration( color: c, shape: BoxShape.circle, border: Border.all( color: selected ? cs.primary : cs.outlineVariant, width: selected ? 3 : 1.5, ), ), ), ); } /// Floating page control: a COMPACT pill (prev / "n / total" / next). Tapping /// the label reveals a drag-slider — which is NOT persistent (collapses again /// on tap) so it doesn't block the page. No keyboard input (Windows IME is /// unreliable). Widget _buildPagePill() { final doc = _document!; final cs = Theme.of(context).colorScheme; final total = doc.pages.length; final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round(); return Column( mainAxisSize: MainAxisSize.min, children: [ // Slider — shown only when expanded (not persistent). if (_showSlider && total > 1) Container( margin: const EdgeInsets.only(bottom: 8), constraints: const BoxConstraints(maxWidth: 420), child: Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Slider( min: 1, max: total.toDouble(), value: (_scrub ?? (_pageIndex + 1).toDouble()) .clamp(1, total.toDouble()), label: '$shown', divisions: total - 1, onChanged: (v) => setState(() => _scrub = v), onChangeEnd: (v) { setState(() => _scrub = null); _goToPage(v.round() - 1); }, ), ), ), ), // Compact pill — always; fits content (no big frame). Material( color: cs.surfaceContainerHigh, elevation: 3, borderRadius: BorderRadius.circular(28), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Previous page', icon: const Icon(Icons.chevron_left), onPressed: _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null, ), TextButton( onPressed: total > 1 ? () => setState(() => _showSlider = !_showSlider) : null, child: Text( '$shown / $total', style: TextStyle( color: cs.onSurface, fontWeight: FontWeight.w600, ), ), ), IconButton( tooltip: 'Next page', icon: const Icon(Icons.chevron_right), onPressed: _pageIndex < total - 1 ? () => _goToPage(_pageIndex + 1) : null, ), ], ), ), ), ], ); } } /// 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, ), ); } }