feat(editor): pen canvas + Material You UI
All checks were successful
CI / Windows build (push) Successful in 8m22s

Clean-room reimplementation of Saber's input model: we own the gesture
pipeline so pen draws with real pressure (perfect_freehand), two-finger
pinch zooms/pans, and palm is rejected (stylus-priority, 2nd-pointer
cancels stroke). pdfrx renders one page at a time (PdfPageView, no
gestures) under a shared transform; page-based nav. Material You theme
via dynamic_color (system accent + seed fallback) and a floating tonal
tool palette + page pill. Old pdfrx-overlay spike no longer wired.
This commit is contained in:
2026-06-21 22:04:49 +08:00
parent 9db688d948
commit 3febbd1431
9 changed files with 993 additions and 37 deletions

View File

@@ -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<PenStroke> 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;
}

View File

@@ -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<PenStroke> 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<PenCanvas> createState() => _PenCanvasState();
}
class _PenCanvasState extends State<PenCanvas> {
/// Active (down) pointers by id → their device kind. Size == pointer count.
final Map<int, PointerDeviceKind> _activePointers = {};
/// The pointer id currently driving a stroke, or null.
int? _drawPointer;
/// In-progress stroke points (normalized).
final List<PenPoint> _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,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -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<PenEditorScreen> createState() => _PenEditorScreenState();
}
class _PenEditorScreenState extends State<PenEditorScreen> {
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<int, List<PenStroke>> _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<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
@override
void initState() {
super.initState();
_open();
}
Future<void> _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<PenStroke> get _currentStrokes =>
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
void _commitStroke(PenStroke stroke) {
setState(() {
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]).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<void> _promptJumpToPage() async {
final doc = _document;
if (doc == null) return;
final controller = TextEditingController(text: '${_pageIndex + 1}');
final result = await showDialog<int>(
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,
),
);
}
}

View File

@@ -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<PenPoint> 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;
}

View File

@@ -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<void> openM1Spike(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
@@ -22,13 +25,7 @@ Future<void> 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),
),
);
}

View File

@@ -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,
),
);
}

View File

@@ -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),
),
],

View File

@@ -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:

View File

@@ -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