diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart new file mode 100644 index 0000000..23b5e95 --- /dev/null +++ b/lib/editor/canvas/ink_painters.dart @@ -0,0 +1,113 @@ +// lib/editor/canvas/ink_painters.dart +// +// CustomPainters for the ink layers. Strokes are stored in normalized page +// coordinates; both painters receive the on-screen page [Size] and scale +// points into pixels at paint time. perfect_freehand produces the outline. + +import 'package:flutter/material.dart'; +import 'package:perfect_freehand/perfect_freehand.dart' as pf; + +import 'pen_stroke.dart'; + +/// Builds a filled outline [Path] for one stroke (already scaled to pixels). +/// +/// [pageSize] maps normalized coords to pixels. [isComplete] should be false +/// for the in-progress live stroke so freehand tapers correctly. +Path buildStrokePath(PenStroke stroke, Size pageSize, {required bool isComplete}) { + final pixelWidth = stroke.width * pageSize.width; + + final hasRealPressure = stroke.points.any((p) => p.pressure != null); + final isHighlighter = stroke.kind == PenStrokeKind.highlighter; + + final pfPoints = stroke.points + .map( + (p) => pf.Point( + p.x * pageSize.width, + p.y * pageSize.height, + p.pressure ?? 0.5, + ), + ) + .toList(); + + final outline = pf.getStroke( + pfPoints, + size: pixelWidth, + // Highlighter keeps a constant width (no thinning); pen thins like the + // existing ink_canvas (_drawFreehand uses 0.7). + thinning: isHighlighter ? 0.0 : 0.7, + smoothing: 0.5, + streamline: 0.5, + // Real stylus pressure → don't simulate; no pressure → let freehand fake + // it based on velocity (matches ink_canvas behavior). + simulatePressure: !hasRealPressure && !isHighlighter, + isComplete: isComplete, + ); + + final path = Path(); + if (outline.isEmpty) return path; + path.moveTo(outline.first.x, outline.first.y); + for (var i = 1; i < outline.length; i++) { + path.lineTo(outline[i].x, outline[i].y); + } + path.close(); + return path; +} + +/// Paints all committed strokes for the page. Repaints only when the stroke +/// list identity or page size changes (kept behind a RepaintBoundary). +class StaticInkPainter extends CustomPainter { + StaticInkPainter({required this.strokes, required this.pageSize}); + + final List strokes; + final Size pageSize; + + @override + void paint(Canvas canvas, Size size) { + for (final stroke in strokes) { + final path = buildStrokePath(stroke, pageSize, isComplete: true); + if (path.getBounds().isEmpty) continue; + canvas.drawPath( + path, + Paint() + ..color = Color(stroke.color) + ..style = PaintingStyle.fill + ..isAntiAlias = true, + ); + } + } + + @override + bool shouldRepaint(StaticInkPainter old) => + !identical(old.strokes, strokes) || + old.strokes.length != strokes.length || + old.pageSize != pageSize; +} + +/// Paints just the in-progress stroke (the live layer), kept behind its own +/// RepaintBoundary so committed strokes don't repaint on every move. +class LiveInkPainter extends CustomPainter { + LiveInkPainter({required this.stroke, required this.pageSize}); + + /// Current in-progress stroke, or null when nothing is being drawn. + final PenStroke? stroke; + final Size pageSize; + + @override + void paint(Canvas canvas, Size size) { + final s = stroke; + if (s == null || s.points.isEmpty) return; + final path = buildStrokePath(s, pageSize, isComplete: false); + if (path.getBounds().isEmpty) return; + canvas.drawPath( + path, + Paint() + ..color = Color(s.color) + ..style = PaintingStyle.fill + ..isAntiAlias = true, + ); + } + + @override + bool shouldRepaint(LiveInkPainter old) => + !identical(old.stroke, stroke) || old.pageSize != pageSize; +} diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart new file mode 100644 index 0000000..9891d54 --- /dev/null +++ b/lib/editor/canvas/pen_canvas.dart @@ -0,0 +1,353 @@ +// lib/editor/canvas/pen_canvas.dart +// +// Pen-first canvas: ONE shared transform (an InteractiveViewer driven by a +// TransformationController we own) zooms/pans BOTH the PDF page bitmap and the +// ink layer together. A Listener wrapped around the InteractiveViewer reads raw +// pointer kind + pressure and tracks the active pointer COUNT to arbitrate +// draw vs pan/zoom — we own the gesture pipeline, pdfrx never sees gestures. +// +// Gesture arbitration (reimplemented clean-room from Saber's documented model): +// - A draw gesture is exactly ONE active pointer that is a stylus / inverted +// stylus, OR (when the user's finger-drawing toggle is on) a single finger. +// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw. If a 2nd +// pointer lands while a stroke is in progress, that stroke is discarded +// (accidental palm/finger). +// - Palm rejection: once any stylus event is seen in a session, finger-drawing +// is forced OFF so a resting palm/finger pans instead of marking. +// - While a stroke is active, the InteractiveViewer's pan is disabled so it +// can't fight the stroke; pinch-zoom still works because a 2nd pointer +// cancels the stroke first, re-enabling pan/zoom. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'ink_painters.dart'; +import 'pen_stroke.dart'; + +/// The active tool on the pen canvas. +enum CanvasTool { pen, highlighter, eraser } + +class PenCanvas extends StatefulWidget { + const PenCanvas({ + super.key, + required this.pageWidget, + required this.pageSize, + required this.strokes, + required this.transformationController, + required this.tool, + required this.color, + required this.strokeWidth, + required this.onStrokeComplete, + required this.onEraseStroke, + this.allowFingerDrawing = false, + this.minScale = 0.5, + this.maxScale = 8.0, + }); + + /// The rendered PDF page bitmap, already sized to [pageSize]. + final Widget pageWidget; + + /// On-screen size (at scale 1.0) of the page rectangle in logical pixels. + /// Ink normalized coords map onto this rectangle. + final Size pageSize; + + /// Committed strokes for the CURRENT page (normalized coords). + final List strokes; + + /// Shared transform driving both page and ink. + final TransformationController transformationController; + + final CanvasTool tool; + final Color color; + + /// Pen width as a fraction of page width (so it zooms with the page). + final double strokeWidth; + + /// Called with a finished stroke (normalized coords) to commit it. + final void Function(PenStroke stroke) onStrokeComplete; + + /// Called with the index of a committed stroke to erase (stroke-erase). + final void Function(int strokeIndex) onEraseStroke; + + /// User toggle: allow a single finger to draw. Forced off once a stylus is + /// seen (palm rejection). + final bool allowFingerDrawing; + + final double minScale; + final double maxScale; + + @override + State createState() => _PenCanvasState(); +} + +class _PenCanvasState extends State { + /// Active (down) pointers by id → their device kind. Size == pointer count. + final Map _activePointers = {}; + + /// The pointer id currently driving a stroke, or null. + int? _drawPointer; + + /// In-progress stroke points (normalized). + final List _livePoints = []; + + /// Live stroke snapshot handed to the LiveInkPainter; null when idle. + PenStroke? _liveStroke; + + /// True once any stylus event is seen this session → finger-drawing forced + /// off so a resting palm pans instead of marking. + bool _stylusSeen = false; + + /// True when the active stylus reports the eraser signal (barrel button or + /// inverted stylus), detected on hover/down. + bool _eraserActive = false; + + bool get _fingerDrawingEnabled => + widget.allowFingerDrawing && !_stylusSeen; + + bool _isStylus(PointerDeviceKind kind) => + kind == PointerDeviceKind.stylus || + kind == PointerDeviceKind.invertedStylus; + + /// Normalize stylus pressure to [0,1], or null when the device reports no + /// usable pressure range (then perfect_freehand simulates pressure). + double? _normalizedPressure(PointerEvent event) { + if (!_isStylus(event.kind)) return null; + if (event.pressureMin == event.pressureMax) return null; + final range = event.pressureMax - event.pressureMin; + return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0); + } + + /// The eraser signal: barrel/secondary button held, or an inverted stylus. + bool _isEraserSignal(PointerEvent event) => + event.buttons == kSecondaryButton || + event.kind == PointerDeviceKind.invertedStylus; + + /// Decide whether the gesture currently forming should DRAW. + /// True iff exactly one active pointer AND (stylus OR finger-drawing on). + bool _shouldDraw(PointerDeviceKind kind) { + if (_activePointers.length != 1) return false; + if (_isStylus(kind)) return true; + if (kind == PointerDeviceKind.mouse) return true; + if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled; + return false; + } + + // --- Coordinate mapping --------------------------------------------------- + + /// Map a global pointer position into normalized page coords using the + /// shared transform (inverse) and this widget's geometry. + PenPoint? _toNormalized(Offset globalPosition, double? pressure) { + final box = context.findRenderObject() as RenderBox?; + if (box == null) return null; + final local = box.globalToLocal(globalPosition); + + // Undo the InteractiveViewer transform to get scene (untransformed) coords. + final scene = widget.transformationController.toScene(local); + + final nx = scene.dx / widget.pageSize.width; + final ny = scene.dy / widget.pageSize.height; + return PenPoint(nx, ny, pressure); + } + + // --- Stroke lifecycle ----------------------------------------------------- + + void _startStroke(PointerDownEvent event) { + _drawPointer = event.pointer; + _livePoints.clear(); + final p = _toNormalized(event.position, _normalizedPressure(event)); + if (p != null) _livePoints.add(p); + + if (_eraserActive || widget.tool == CanvasTool.eraser) { + _eraseAt(p); + // Keep the stroke pointer reserved so moves keep erasing, but don't paint. + setState(() => _liveStroke = null); + return; + } + _updateLiveStroke(); + } + + void _extendStroke(PointerMoveEvent event) { + final p = _toNormalized(event.position, _normalizedPressure(event)); + if (p == null) return; + + if (_eraserActive || widget.tool == CanvasTool.eraser) { + _eraseAt(p); + return; + } + _livePoints.add(p); + _updateLiveStroke(); + } + + void _endStroke() { + if (_drawPointer == null) return; + final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser; + if (!wasEraser && _livePoints.isNotEmpty) { + widget.onStrokeComplete( + PenStroke( + points: List.of(_livePoints), + color: _currentColor().toARGB32(), + width: widget.strokeWidth, + kind: _currentKind(), + ), + ); + } + _drawPointer = null; + _livePoints.clear(); + setState(() => _liveStroke = null); + } + + /// Discard the in-progress stroke without committing (palm/2nd-finger). + void _cancelStroke() { + _drawPointer = null; + _livePoints.clear(); + setState(() => _liveStroke = null); + } + + void _updateLiveStroke() { + setState(() { + _liveStroke = PenStroke( + points: List.of(_livePoints), + color: _currentColor().toARGB32(), + width: widget.strokeWidth, + kind: _currentKind(), + ); + }); + } + + PenStrokeKind _currentKind() => + widget.tool == CanvasTool.highlighter + ? PenStrokeKind.highlighter + : PenStrokeKind.pen; + + Color _currentColor() => widget.tool == CanvasTool.highlighter + ? widget.color.withAlpha(0x80) + : widget.color; + + /// Stroke-erase: remove the first committed stroke within proximity of [p]. + void _eraseAt(PenPoint? p) { + if (p == null) return; + final radius = widget.strokeWidth * 2; // normalized radius + for (var i = widget.strokes.length - 1; i >= 0; i--) { + final stroke = widget.strokes[i]; + for (final sp in stroke.points) { + final dx = sp.x - p.x; + final dy = sp.y - p.y; + if (dx * dx + dy * dy < radius * radius) { + widget.onEraseStroke(i); + return; + } + } + } + } + + // --- Listener callbacks --------------------------------------------------- + + void _onPointerHover(PointerHoverEvent event) { + if (_isStylus(event.kind)) { + _stylusSeen = true; + // Detect eraser (barrel button / inverted) while hovering. + _eraserActive = _isEraserSignal(event); + } + } + + void _onPointerDown(PointerDownEvent event) { + if (event.kind == PointerDeviceKind.trackpad) return; + if (_isStylus(event.kind)) _stylusSeen = true; + + _activePointers[event.pointer] = event.kind; + + // A 2nd pointer arriving during a stroke = pinch/palm → cancel the stroke + // and let the InteractiveViewer take over pan/zoom. + if (_activePointers.length >= 2) { + if (_drawPointer != null) _cancelStroke(); + return; + } + + // Single pointer: decide draw vs pan. Eraser is on if this stylus down + // signals it (barrel button / inverted), or hover already flagged it. + if (_isStylus(event.kind)) { + _eraserActive = _eraserActive || _isEraserSignal(event); + } else { + _eraserActive = false; + } + + if (_shouldDraw(event.kind)) { + _startStroke(event); + } + } + + void _onPointerMove(PointerMoveEvent event) { + if (event.pointer != _drawPointer) return; + if (_activePointers.length >= 2) return; // pinch owns it + _extendStroke(event); + } + + void _onPointerUp(PointerUpEvent event) { + final wasDrawer = event.pointer == _drawPointer; + _activePointers.remove(event.pointer); + if (wasDrawer) _endStroke(); + } + + void _onPointerCancel(PointerCancelEvent event) { + final wasDrawer = event.pointer == _drawPointer; + _activePointers.remove(event.pointer); + if (wasDrawer) _cancelStroke(); + } + + @override + Widget build(BuildContext context) { + // Pan only when NOT mid-stroke; while drawing we suppress IV pan so it + // can't fight the stroke. (A 2nd finger cancels the stroke first, so pinch + // re-enables pan/zoom immediately.) + final panEnabled = _drawPointer == null; + + return Listener( + onPointerHover: _onPointerHover, + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, + child: InteractiveViewer( + transformationController: widget.transformationController, + minScale: widget.minScale, + maxScale: widget.maxScale, + panEnabled: panEnabled, + scaleEnabled: true, + constrained: false, + boundaryMargin: const EdgeInsets.all(double.infinity), + child: SizedBox( + width: widget.pageSize.width, + height: widget.pageSize.height, + child: Stack( + children: [ + // PDF page bitmap. + Positioned.fill(child: widget.pageWidget), + // Committed ink (static layer, isolated repaint). + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint( + painter: StaticInkPainter( + strokes: widget.strokes, + pageSize: widget.pageSize, + ), + ), + ), + ), + // Live ink (current stroke only, isolated repaint). + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint( + painter: LiveInkPainter( + stroke: _liveStroke, + pageSize: widget.pageSize, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart new file mode 100644 index 0000000..57eeabe --- /dev/null +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -0,0 +1,430 @@ +// 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, + ), + ); + } +} diff --git a/lib/editor/canvas/pen_stroke.dart b/lib/editor/canvas/pen_stroke.dart new file mode 100644 index 0000000..358d9a2 --- /dev/null +++ b/lib/editor/canvas/pen_stroke.dart @@ -0,0 +1,47 @@ +// lib/editor/canvas/pen_stroke.dart +// +// Pen-first canvas stroke model. Points are stored in NORMALIZED page +// coordinates ([0,1] x [0,1] relative to the page rectangle) so strokes stay +// pinned to the page regardless of zoom/pan or the on-screen page size. + +import 'package:flutter/foundation.dart'; + +/// A single captured sample of a stroke. +/// +/// [x]/[y] are normalized to the page rectangle ([0,1]). +/// [pressure] is the normalized stylus pressure ([0,1]) or null when the +/// device reported no usable pressure (perfect_freehand then simulates it). +@immutable +class PenPoint { + const PenPoint(this.x, this.y, this.pressure); + + final double x; + final double y; + final double? pressure; +} + +/// Which kind of mark a stroke is. +enum PenStrokeKind { pen, highlighter } + +/// A committed stroke for a single page, in normalized page coordinates. +@immutable +class PenStroke { + const PenStroke({ + required this.points, + required this.color, + required this.width, + required this.kind, + }); + + /// Normalized points (see [PenPoint]). + final List points; + + /// ARGB color value. + final int color; + + /// Stroke width expressed as a fraction of the page width, so it scales with + /// the page when zoomed. Multiply by the on-screen page width to get pixels. + final double width; + + final PenStrokeKind kind; +} diff --git a/lib/editor/pdf/spike_launcher.dart b/lib/editor/pdf/spike_launcher.dart index fb4ca03..ef636a9 100644 --- a/lib/editor/pdf/spike_launcher.dart +++ b/lib/editor/pdf/spike_launcher.dart @@ -8,10 +8,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'spike_editor_pane.dart'; +import '../canvas/pen_editor_screen.dart'; -/// Opens a file picker for a PDF, then pushes the spike pane on it. -/// Pass the user's own large PDF to get a realistic 60fps / pen test. +/// Opens a file picker for a PDF, then pushes the NEW pen-first canvas editor. +/// +/// The 🧪 entry now opens the clean-room canvas (lib/editor/canvas/), which +/// OWNS the gesture pipeline (pressure, pinch-zoom, palm rejection). The old +/// spike_* files are left in place but no longer wired to this entry. Future openM1Spike(BuildContext context) async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -22,13 +25,7 @@ Future openM1Spike(BuildContext context) async { if (!context.mounted) return; Navigator.of(context).push( MaterialPageRoute( - builder: (_) => Scaffold( - appBar: AppBar(title: const Text('M1 Spike — pen / scroll / zoom')), - // denseStrokesAsset is null here: the bundled synthetic-stroke load is - // only for the perf bench. For manual on-device testing, draw real ink - // and scroll a real large PDF while watching the frame-time HUD. - body: SpikeEditorPane(pdfPath: path), - ), + builder: (_) => PenEditorScreen(pdfPath: path), ), ); } diff --git a/lib/main.dart b/lib/main.dart index 99ba197..e7e6001 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -34,30 +35,36 @@ class BadNoteApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); - return MaterialApp( - title: 'BadNote', - themeMode: settings.themeMode, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: settings.colorSchemeSeed, - brightness: Brightness.light, - ), - textTheme: GoogleFonts.interTextTheme( - ThemeData(brightness: Brightness.light).textTheme, - ), - useMaterial3: true, - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: settings.colorSchemeSeed, - brightness: Brightness.dark, - ), - textTheme: GoogleFonts.interTextTheme( - ThemeData(brightness: Brightness.dark).textTheme, - ), - useMaterial3: true, - ), - home: const HomeScreen(), + // Material You: prefer the OS dynamic color (Windows/Android system accent); + // fall back to the user's seed color when the platform provides none. + return DynamicColorBuilder( + builder: (lightDynamic, darkDynamic) { + final lightScheme = lightDynamic?.harmonized() ?? + ColorScheme.fromSeed( + seedColor: settings.colorSchemeSeed, + brightness: Brightness.light, + ); + final darkScheme = darkDynamic?.harmonized() ?? + ColorScheme.fromSeed( + seedColor: settings.colorSchemeSeed, + brightness: Brightness.dark, + ); + return MaterialApp( + title: 'BadNote', + themeMode: settings.themeMode, + theme: _theme(lightScheme), + darkTheme: _theme(darkScheme), + home: const HomeScreen(), + ); + }, ); } + + ThemeData _theme(ColorScheme scheme) => ThemeData( + colorScheme: scheme, + useMaterial3: true, + textTheme: GoogleFonts.interTextTheme( + ThemeData(brightness: scheme.brightness).textTheme, + ), + ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7987ae8..d5b71c8 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -68,10 +68,10 @@ class HomeScreen extends ConsumerWidget { ).push(MaterialPageRoute(builder: (_) => const SearchScreen())); }, ), - // THROWAWAY M1 spike entry (remove with lib/editor/pdf/spike_*). + // New pen-first canvas editor (beta). IconButton( - icon: const Icon(Icons.science_outlined), - tooltip: 'M1 Spike (pen/scroll/zoom test)', + icon: const Icon(Icons.draw_outlined), + tooltip: 'Pen Canvas (beta)', onPressed: () => openM1Spike(context), ), ], diff --git a/pubspec.lock b/pubspec.lock index 97a93a3..ca81706 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + dynamic_color: + dependency: "direct main" + description: + name: dynamic_color + sha256: "43a5a6679649a7731ab860334a5812f2067c2d9ce6452cf069c5e0c25336c17c" + url: "https://pub.dev" + source: hosted + version: "1.8.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59b57c7..b96c92b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: # Embedded ONNX runtime (local OCR recognition backend) flutter_onnxruntime: ^1.8.0 pdfrx: ^2.4.4 + dynamic_color: ^1.8.1 # Pin sqlite3 to the exact version whose native binaries are vendored under # vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the