// 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 'pen_canvas.dart'; import 'pen_stroke.dart'; 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 = {}; /// One shared transform for the current page; reset on page change so each /// page opens fit-to-view. final TransformationController _transform = TransformationController(); // Tool state. CanvasTool _tool = CanvasTool.pen; Color _color = Colors.black; bool _allowFingerDrawing = false; /// Pen width as a fraction of page width. static const double _penWidthFraction = 0.004; 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(); _open(); } 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() { _document?.dispose(); _transform.dispose(); super.dispose(); } List get _currentStrokes => _strokesByPage.putIfAbsent(_pageIndex, () => []); void _commitStroke(PenStroke stroke) { setState(() { _strokesByPage.putIfAbsent(_pageIndex, () => []).add(stroke); }); } void _eraseStroke(int index) { setState(() { final list = _strokesByPage[_pageIndex]; if (list != null && index >= 0 && index < list.length) { list.removeAt(index); } }); } 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; _transform.value = Matrix4.identity(); }); } Future _promptJumpToPage() async { final doc = _document; if (doc == null) return; final controller = TextEditingController(text: '${_pageIndex + 1}'); final result = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Go to page'), content: TextField( controller: controller, autofocus: true, keyboardType: TextInputType.number, decoration: InputDecoration(hintText: '1 – ${doc.pages.length}'), onSubmitted: (v) => Navigator.of(context).pop(int.tryParse(v.trim())), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(int.tryParse(controller.text.trim())), child: const Text('Go'), ), ], ), ); if (result != null) _goToPage(result - 1); } @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(), ), ), ), ], ), ); } 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); return Center( child: PenCanvas( key: ValueKey(_pageIndex), pageSize: pageSize, strokes: _currentStrokes, transformationController: _transform, tool: _tool, color: _color, strokeWidth: _tool == CanvasTool.highlighter ? _highlighterWidthFraction : _penWidthFraction, allowFingerDrawing: _allowFingerDrawing, 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), 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: () => setState(() => _allowFingerDrawing = !_allowFingerDrawing), ), ], ), ), ); } 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 pill: prev / "n / total" / next. Widget _buildPagePill() { final doc = _document!; final cs = Theme.of(context).colorScheme; return 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: _promptJumpToPage, child: Text( '${_pageIndex + 1} / ${doc.pages.length}', style: TextStyle( color: cs.onSurface, fontWeight: FontWeight.w600, ), ), ), IconButton( tooltip: 'Next page', icon: const Icon(Icons.chevron_right), onPressed: _pageIndex < doc.pages.length - 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; final VoidCallback onPressed; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; 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: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant, ), ), ), ); } } 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, ), ); } }