Compare commits
4 Commits
682907614d
...
eca5141372
| Author | SHA1 | Date | |
|---|---|---|---|
| eca5141372 | |||
| f64e6561a0 | |||
| d50087247c | |||
| a48c0e7e56 |
@@ -23,9 +23,15 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../engine/stroke_store.dart';
|
||||||
|
import '../input/input_arbiter.dart' as arbiter;
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
import 'ink_painters.dart';
|
import '../render/ink_picture_cache.dart';
|
||||||
|
import '../render/live_ink_painter.dart' as render;
|
||||||
|
import '../render/static_ink_painter.dart' as render;
|
||||||
|
import 'ink_painters.dart' show EraserPreviewPainter;
|
||||||
import 'pen_interactive_viewer.dart';
|
import 'pen_interactive_viewer.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
@@ -135,6 +141,28 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
/// (the old per-move setState was the eraser-lag source).
|
/// (the old per-move setState was the eraser-lag source).
|
||||||
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);
|
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);
|
||||||
|
|
||||||
|
/// Committed ink mirrored as the canonical [EditorStroke] model, driving the
|
||||||
|
/// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
|
||||||
|
/// The cache replays a recorded ui.Picture for the committed layer, so pinch /
|
||||||
|
/// pan / live-stroke frames never re-rasterize the committed ink — the
|
||||||
|
/// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
|
||||||
|
/// parent replaces with a fresh list identity on every commit/erase).
|
||||||
|
final StrokeStore _store = StrokeStore();
|
||||||
|
final InkPictureCache _inkCache = InkPictureCache();
|
||||||
|
List<PenStroke>? _syncedStrokesRef;
|
||||||
|
static const String _inkHostId = 'pen-canvas';
|
||||||
|
|
||||||
|
/// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
|
||||||
|
/// list (identity change ⇒ a commit/erase happened). Bumping the store
|
||||||
|
/// revision invalidates the cached Picture so the committed layer repaints.
|
||||||
|
void _syncStore() {
|
||||||
|
if (identical(_syncedStrokesRef, widget.strokes)) return;
|
||||||
|
_syncedStrokesRef = widget.strokes;
|
||||||
|
_store.replaceAll(
|
||||||
|
widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
|
/// True when the eraser would act (eraser tool selected, or a barrel/inverted
|
||||||
/// eraser signal is live).
|
/// eraser signal is live).
|
||||||
bool get _isEraserMode =>
|
bool get _isEraserMode =>
|
||||||
@@ -157,9 +185,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
// cancels an in-progress stroke regardless.)
|
// cancels an in-progress stroke regardless.)
|
||||||
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
bool get _fingerDrawingEnabled => widget.allowFingerDrawing;
|
||||||
|
|
||||||
bool _isStylus(PointerDeviceKind kind) =>
|
bool _isStylus(PointerDeviceKind kind) => arbiter.isStylusKind(kind);
|
||||||
kind == PointerDeviceKind.stylus ||
|
|
||||||
kind == PointerDeviceKind.invertedStylus;
|
|
||||||
|
|
||||||
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
/// Normalize stylus pressure to [0,1], or null when the device reports no
|
||||||
/// usable pressure range (then perfect_freehand simulates pressure).
|
/// usable pressure range (then perfect_freehand simulates pressure).
|
||||||
@@ -249,18 +275,15 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
return t == 0 ? null : t;
|
return t == 0 ? null : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide whether the gesture currently forming should DRAW.
|
/// Decide whether the gesture currently forming should DRAW. Delegates to the
|
||||||
/// True iff exactly one active pointer AND (stylus OR finger-drawing on).
|
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
|
||||||
bool _shouldDraw(PointerDeviceKind kind) {
|
/// the tests can never disagree on the rule.
|
||||||
if (_activePointers.length != 1) return false;
|
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
|
||||||
// A hardware pen button mapped to `pan` suppresses drawing so the
|
activePointerCount: _activePointers.length,
|
||||||
// InteractiveViewer pans instead.
|
kind: kind,
|
||||||
if (_hwPanActive) return false;
|
fingerDrawingEnabled: _fingerDrawingEnabled,
|
||||||
if (_isStylus(kind)) return true;
|
hwPanActive: _hwPanActive,
|
||||||
if (kind == PointerDeviceKind.mouse) return true;
|
);
|
||||||
if (kind == PointerDeviceKind.touch) return _fingerDrawingEnabled;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Coordinate mapping ---------------------------------------------------
|
// --- Coordinate mapping ---------------------------------------------------
|
||||||
|
|
||||||
@@ -466,6 +489,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_eraserCursor.dispose();
|
_eraserCursor.dispose();
|
||||||
|
_inkCache.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +502,12 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
// so a pinch re-enables pan/zoom immediately.
|
// so a pinch re-enables pan/zoom immediately.
|
||||||
final panEnabled = _drawPointer == null;
|
final panEnabled = _drawPointer == null;
|
||||||
|
|
||||||
|
// Mirror committed strokes into the revision-tracked store (only re-mirrors
|
||||||
|
// when the parent handed us a new list identity).
|
||||||
|
_syncStore();
|
||||||
|
final liveEditorStroke =
|
||||||
|
_liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);
|
||||||
|
|
||||||
return Listener(
|
return Listener(
|
||||||
onPointerHover: _onPointerHover,
|
onPointerHover: _onPointerHover,
|
||||||
onPointerDown: _onPointerDown,
|
onPointerDown: _onPointerDown,
|
||||||
@@ -504,13 +534,17 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RepaintBoundary(child: widget.pageWidget),
|
child: RepaintBoundary(child: widget.pageWidget),
|
||||||
),
|
),
|
||||||
// Committed ink (static layer, isolated repaint).
|
// Committed ink (static layer, isolated repaint). Backed by the
|
||||||
|
// revision-gated ui.Picture cache (P0 step 3): unchanged across
|
||||||
|
// pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: StaticInkPainter(
|
painter: render.StaticInkPainter(
|
||||||
strokes: widget.strokes,
|
hostId: _inkHostId,
|
||||||
|
store: _store,
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
|
cache: _inkCache,
|
||||||
thinning: widget.thinning,
|
thinning: widget.thinning,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -536,8 +570,8 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: LiveInkPainter(
|
painter: render.LiveInkPainter(
|
||||||
stroke: _liveStroke,
|
live: liveEditorStroke,
|
||||||
pageSize: widget.pageSize,
|
pageSize: widget.pageSize,
|
||||||
thinning: widget.thinning,
|
thinning: widget.thinning,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -32,6 +32,45 @@ const double kDefaultPenThinning = 0.85;
|
|||||||
const double kPenStreamline = 0.32;
|
const double kPenStreamline = 0.32;
|
||||||
const double kPenSmoothing = 0.5;
|
const double kPenSmoothing = 0.5;
|
||||||
|
|
||||||
|
/// THE single perfect_freehand outline recipe — the raw outline points for a
|
||||||
|
/// stroke. Both the on-screen painter ([buildStrokeOutline] / the live
|
||||||
|
/// `ink_painters.buildStrokePath`) and the PDF export
|
||||||
|
/// (`pdf_service._buildFreehandPdfPath`) call THIS, so the `StrokeOptions`
|
||||||
|
/// (thinning / smoothing / streamline / simulatePressure) live in exactly one
|
||||||
|
/// place and screen↔export can never drift again (R7 — the hairline-export bug
|
||||||
|
/// was pdf_service hardcoding its own `thinning: 0.7, streamline: 0.5`).
|
||||||
|
///
|
||||||
|
/// Callers supply already-pixel-scaled [pfPoints] (because the two stroke
|
||||||
|
/// models scale differently) plus the per-stroke flags. Returns the closed
|
||||||
|
/// outline as `List<Offset>` (perfect_freehand 2.x); empty when freehand
|
||||||
|
/// produces nothing.
|
||||||
|
List<Offset> freehandOutlinePoints({
|
||||||
|
required List<pf.PointVector> pfPoints,
|
||||||
|
required double size,
|
||||||
|
required bool isHighlighter,
|
||||||
|
required bool hasRealPressure,
|
||||||
|
required bool isComplete,
|
||||||
|
double thinning = kDefaultPenThinning,
|
||||||
|
}) {
|
||||||
|
if (pfPoints.isEmpty) return const <Offset>[];
|
||||||
|
return pf.getStroke(
|
||||||
|
pfPoints,
|
||||||
|
options: pf.StrokeOptions(
|
||||||
|
size: size,
|
||||||
|
// Highlighter keeps a constant width (no thinning); pen uses the
|
||||||
|
// configurable [thinning] so Surface-Pen pressure changes width.
|
||||||
|
thinning: isHighlighter ? 0.0 : thinning,
|
||||||
|
smoothing: kPenSmoothing,
|
||||||
|
streamline: kPenStreamline,
|
||||||
|
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
||||||
|
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
||||||
|
// 2.x honors real pressure when simulatePressure is false.
|
||||||
|
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||||
|
isComplete: isComplete,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
||||||
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
||||||
///
|
///
|
||||||
@@ -52,11 +91,6 @@ Path buildStrokeOutline(
|
|||||||
final path = Path();
|
final path = Path();
|
||||||
if (stroke.points.isEmpty) return path;
|
if (stroke.points.isEmpty) return path;
|
||||||
|
|
||||||
final pixelWidth = stroke.width * pageSize.width;
|
|
||||||
|
|
||||||
final hasRealPressure = stroke.points.any((p) => p.pressure != null);
|
|
||||||
final isHighlighter = stroke.tool == EditorTool.highlighter;
|
|
||||||
|
|
||||||
final pfPoints = stroke.points
|
final pfPoints = stroke.points
|
||||||
.map(
|
.map(
|
||||||
(p) => pf.PointVector(
|
(p) => pf.PointVector(
|
||||||
@@ -67,21 +101,13 @@ Path buildStrokeOutline(
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final outline = pf.getStroke(
|
final outline = freehandOutlinePoints(
|
||||||
pfPoints,
|
pfPoints: pfPoints,
|
||||||
options: pf.StrokeOptions(
|
size: stroke.width * pageSize.width,
|
||||||
size: pixelWidth,
|
isHighlighter: stroke.tool == EditorTool.highlighter,
|
||||||
// Highlighter keeps a constant width (no thinning); pen uses the
|
hasRealPressure: stroke.points.any((p) => p.pressure != null),
|
||||||
// configurable [thinning] so Surface-Pen pressure changes width.
|
isComplete: isComplete,
|
||||||
thinning: isHighlighter ? 0.0 : thinning,
|
thinning: thinning,
|
||||||
smoothing: kPenSmoothing,
|
|
||||||
streamline: kPenStreamline,
|
|
||||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
|
||||||
// fake it based on velocity (highlighter never simulates). perfect_freehand
|
|
||||||
// 2.x honors real pressure when simulatePressure is false.
|
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
|
||||||
isComplete: isComplete,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outline.isEmpty) return path;
|
if (outline.isEmpty) return path;
|
||||||
|
|||||||
46
lib/editor/input/input_arbiter.dart
Normal file
46
lib/editor/input/input_arbiter.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// lib/editor/input/input_arbiter.dart
|
||||||
|
//
|
||||||
|
// Pure draw-vs-pan/zoom arbitration for the pen-first canvas (P0 step 4 —
|
||||||
|
// extracted verbatim from `pen_canvas.dart` so the make-or-break gesture rules
|
||||||
|
// are decided by ONE testable place rather than inline in a StatefulWidget).
|
||||||
|
//
|
||||||
|
// The model (clean-room from Saber, proven live):
|
||||||
|
// - A DRAW gesture is exactly ONE active pointer that is a stylus / inverted
|
||||||
|
// stylus / mouse, OR (when the finger-drawing toggle is on) a single finger.
|
||||||
|
// - >= 2 active pointers ALWAYS means pan/zoom (pinch); never draw.
|
||||||
|
// - Palm rejection: a finger never draws unless the user explicitly enabled
|
||||||
|
// finger-drawing — so a resting palm pans (or is ignored) instead of marking.
|
||||||
|
// - A hardware pen button mapped to `pan` suppresses drawing so the shared
|
||||||
|
// InteractiveViewer pans instead.
|
||||||
|
//
|
||||||
|
// These are PURE functions (no widget/IO state) so the whole truth table is
|
||||||
|
// unit-tested; `pen_canvas.dart` owns the live pointer map and delegates the
|
||||||
|
// decisions here.
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
||||||
|
|
||||||
|
/// Whether [kind] is a pen (tip or flipped eraser end).
|
||||||
|
bool isStylusKind(PointerDeviceKind kind) =>
|
||||||
|
kind == PointerDeviceKind.stylus ||
|
||||||
|
kind == PointerDeviceKind.invertedStylus;
|
||||||
|
|
||||||
|
/// Decide whether the gesture currently forming should DRAW.
|
||||||
|
///
|
||||||
|
/// True iff there is exactly one active pointer, drawing is not suppressed by a
|
||||||
|
/// hardware pan button, and the pointer is a draw device:
|
||||||
|
/// - stylus / inverted stylus → always draws,
|
||||||
|
/// - mouse → always draws (desktop authoring),
|
||||||
|
/// - touch → draws only when [fingerDrawingEnabled] (else it pans / is palm).
|
||||||
|
bool shouldDraw({
|
||||||
|
required int activePointerCount,
|
||||||
|
required PointerDeviceKind kind,
|
||||||
|
required bool fingerDrawingEnabled,
|
||||||
|
required bool hwPanActive,
|
||||||
|
}) {
|
||||||
|
if (activePointerCount != 1) return false;
|
||||||
|
if (hwPanActive) return false;
|
||||||
|
if (isStylusKind(kind)) return true;
|
||||||
|
if (kind == PointerDeviceKind.mouse) return true;
|
||||||
|
if (kind == PointerDeviceKind.touch) return fingerDrawingEnabled;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -18,18 +18,25 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
const LiveInkPainter({
|
const LiveInkPainter({
|
||||||
required this.live,
|
required this.live,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
|
this.thinning = kDefaultPenThinning,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The stroke currently being drawn, or null when idle.
|
/// The stroke currently being drawn, or null when idle.
|
||||||
final EditorStroke? live;
|
final EditorStroke? live;
|
||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
|
|
||||||
|
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`),
|
||||||
|
/// kept consistent with the static layer so the stroke doesn't change width
|
||||||
|
/// the instant it commits.
|
||||||
|
final double thinning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final stroke = live;
|
final stroke = live;
|
||||||
if (stroke == null || stroke.points.isEmpty) return;
|
if (stroke == null || stroke.points.isEmpty) return;
|
||||||
|
|
||||||
final path = buildStrokeOutline(stroke, pageSize, isComplete: false);
|
final path = buildStrokeOutline(stroke, pageSize,
|
||||||
|
isComplete: false, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) return;
|
if (path.getBounds().isEmpty) return;
|
||||||
|
|
||||||
canvas.drawPath(
|
canvas.drawPath(
|
||||||
@@ -43,5 +50,7 @@ class LiveInkPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(LiveInkPainter old) =>
|
bool shouldRepaint(LiveInkPainter old) =>
|
||||||
!identical(old.live, live) || old.pageSize != pageSize;
|
!identical(old.live, live) ||
|
||||||
|
old.pageSize != pageSize ||
|
||||||
|
old.thinning != thinning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
required this.store,
|
required this.store,
|
||||||
required this.pageSize,
|
required this.pageSize,
|
||||||
required this.cache,
|
required this.cache,
|
||||||
|
this.thinning = kDefaultPenThinning,
|
||||||
}) : revision = store.revision;
|
}) : revision = store.revision;
|
||||||
|
|
||||||
final String hostId;
|
final String hostId;
|
||||||
@@ -35,6 +36,11 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
final Size pageSize;
|
final Size pageSize;
|
||||||
final InkPictureCache cache;
|
final InkPictureCache cache;
|
||||||
|
|
||||||
|
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`).
|
||||||
|
/// Folded into the cache key + [shouldRepaint] so a sensitivity change can't
|
||||||
|
/// replay a stale Picture built at the old thinning.
|
||||||
|
final double thinning;
|
||||||
|
|
||||||
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
|
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
|
||||||
/// so two painters built at different revisions compare correctly even when
|
/// so two painters built at different revisions compare correctly even when
|
||||||
/// they share the same [StrokeStore] instance.
|
/// they share the same [StrokeStore] instance.
|
||||||
@@ -42,12 +48,15 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final picture = cache.getOrBuild(hostId, store.revision, pageSize, () {
|
// thinning is part of the cache identity (different thinning ⇒ different
|
||||||
|
// outline) so it MUST be in the key, not just shouldRepaint.
|
||||||
|
final cacheKey = '$hostId#${thinning.toStringAsFixed(4)}';
|
||||||
|
final picture = cache.getOrBuild(cacheKey, store.revision, pageSize, () {
|
||||||
final recorder = ui.PictureRecorder();
|
final recorder = ui.PictureRecorder();
|
||||||
final rec = Canvas(recorder);
|
final rec = Canvas(recorder);
|
||||||
for (final stroke in store.committed) {
|
for (final stroke in store.committed) {
|
||||||
final path =
|
final path = buildStrokeOutline(stroke, pageSize,
|
||||||
buildStrokeOutline(stroke, pageSize, isComplete: true);
|
isComplete: true, thinning: thinning);
|
||||||
if (path.getBounds().isEmpty) continue;
|
if (path.getBounds().isEmpty) continue;
|
||||||
rec.drawPath(
|
rec.drawPath(
|
||||||
path,
|
path,
|
||||||
@@ -65,5 +74,7 @@ class StaticInkPainter extends CustomPainter {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRepaint(StaticInkPainter old) =>
|
bool shouldRepaint(StaticInkPainter old) =>
|
||||||
old.revision != store.revision || old.pageSize != pageSize;
|
old.revision != store.revision ||
|
||||||
|
old.pageSize != pageSize ||
|
||||||
|
old.thinning != thinning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||||
|
|
||||||
|
import '../editor/engine/stroke_geometry.dart' show freehandOutlinePoints;
|
||||||
import '../models/ink_stroke.dart';
|
import '../models/ink_stroke.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
|
|
||||||
@@ -304,19 +305,15 @@ class PdfService {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final outline = pf.getStroke(
|
// ONE shared recipe with the on-screen painter (R7): export can no longer
|
||||||
pfPoints,
|
// drift from screen. Previously this hardcoded thinning:0.7/streamline:0.5,
|
||||||
options: pf.StrokeOptions(
|
// which diverged from the screen's 0.85/0.32 → hairline export mismatch.
|
||||||
size: pixelWidth,
|
final outline = freehandOutlinePoints(
|
||||||
// Highlighter keeps constant width; pen/marker taper via thinning=0.7.
|
pfPoints: pfPoints,
|
||||||
thinning: isHighlighter ? 0.0 : 0.7,
|
size: pixelWidth,
|
||||||
smoothing: 0.5,
|
isHighlighter: isHighlighter,
|
||||||
streamline: 0.5,
|
hasRealPressure: hasRealPressure,
|
||||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
isComplete: true,
|
||||||
// fake it based on velocity. Highlighter never simulates.
|
|
||||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
|
||||||
isComplete: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outline.isEmpty) return null;
|
if (outline.isEmpty) return null;
|
||||||
|
|||||||
@@ -9,12 +9,15 @@
|
|||||||
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
|
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
|
||||||
// pageSize so we can exercise it without a real ui.Picture or Canvas.
|
// pageSize so we can exercise it without a real ui.Picture or Canvas.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
import 'package:badnote/editor/engine/stroke_store.dart';
|
import 'package:badnote/editor/engine/stroke_store.dart';
|
||||||
import 'package:badnote/editor/render/ink_picture_cache.dart';
|
import 'package:badnote/editor/render/ink_picture_cache.dart';
|
||||||
|
import 'package:badnote/editor/render/live_ink_painter.dart';
|
||||||
import 'package:badnote/editor/render/static_ink_painter.dart';
|
import 'package:badnote/editor/render/static_ink_painter.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -156,5 +159,84 @@ void main() {
|
|||||||
// pNew.revision (3) != pOld.revision (2) → should repaint.
|
// pNew.revision (3) != pOld.revision (2) → should repaint.
|
||||||
expect(pNew.shouldRepaint(pOld), isTrue);
|
expect(pNew.shouldRepaint(pOld), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// thinning (PenConfig.pressureSensitivity) participates in the static layer
|
||||||
|
// identity: a sensitivity change rebuilds the outline, so it MUST trigger a
|
||||||
|
// repaint even when revision + pageSize are unchanged — otherwise the cached
|
||||||
|
// Picture (built at the old thinning) would be replayed (stale ink).
|
||||||
|
test('returns true when only thinning changes (no stale cached Picture)',
|
||||||
|
() {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final cache = InkPictureCache();
|
||||||
|
final pThin = StaticInkPainter(
|
||||||
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
||||||
|
thinning: 0.85);
|
||||||
|
final pThick = StaticInkPainter(
|
||||||
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
||||||
|
thinning: 0.2);
|
||||||
|
expect(pThick.shouldRepaint(pThin), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when thinning is equal (revision+size unchanged)', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final cache = InkPictureCache();
|
||||||
|
StaticInkPainter mk() => StaticInkPainter(
|
||||||
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
||||||
|
thinning: 0.6);
|
||||||
|
expect(mk().shouldRepaint(mk()), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
group('StaticInkPainter.paint (cache + thinning recipe)', () {
|
||||||
|
test('paints committed strokes into a Picture without error', () {
|
||||||
|
final store = StrokeStore()
|
||||||
|
..add(_stroke('a'))
|
||||||
|
..add(_stroke('b'));
|
||||||
|
final painter = _painter(store, pageSize);
|
||||||
|
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
painter.paint(Canvas(recorder), pageSize);
|
||||||
|
final picture = recorder.endRecording();
|
||||||
|
addTearDown(picture.dispose);
|
||||||
|
expect(picture, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a thinning change builds a fresh Picture (cache key includes thinning)',
|
||||||
|
() {
|
||||||
|
final store = StrokeStore()..add(_stroke('a'));
|
||||||
|
final cache = InkPictureCache();
|
||||||
|
// Same store/revision/size, different thinning, shared cache.
|
||||||
|
for (final t in [0.85, 0.2]) {
|
||||||
|
final painter = StaticInkPainter(
|
||||||
|
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
|
||||||
|
thinning: t);
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
painter.paint(Canvas(recorder), pageSize);
|
||||||
|
recorder.endRecording().dispose();
|
||||||
|
}
|
||||||
|
// If the key ignored thinning, the 2nd paint would replay the 1st's
|
||||||
|
// Picture; the test simply asserts both paints complete (distinct keys,
|
||||||
|
// no aliasing assertion error from drawing a disposed picture).
|
||||||
|
expect(true, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
group('LiveInkPainter.shouldRepaint', () {
|
||||||
|
test('repaints when the live stroke identity changes', () {
|
||||||
|
const a = LiveInkPainter(live: null, pageSize: pageSize);
|
||||||
|
final b = LiveInkPainter(
|
||||||
|
live: _stroke('live'), pageSize: pageSize);
|
||||||
|
expect(b.shouldRepaint(a), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repaints when thinning changes (width consistent with static layer)',
|
||||||
|
() {
|
||||||
|
final stroke = _stroke('live');
|
||||||
|
final a = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.85);
|
||||||
|
final b = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.2);
|
||||||
|
expect(b.shouldRepaint(a), isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
117
test/export_geometry_test.dart
Normal file
117
test/export_geometry_test.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// R7 regression (P0 step 7/8): the on-screen painter and the PDF export must
|
||||||
|
// build their freehand outline from ONE shared recipe. The export bug was
|
||||||
|
// pdf_service hardcoding `thinning: 0.7, streamline: 0.5` while the screen used
|
||||||
|
// 0.85 / 0.32 — a hairline mismatch. These tests pin the single source so a
|
||||||
|
// future edit to one side can't silently re-diverge.
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const size = Size(800, 600);
|
||||||
|
|
||||||
|
EditorStroke pen() => EditorStroke.create(
|
||||||
|
id: 'a',
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.1, y: 0.1, pressure: 0.9),
|
||||||
|
EditorPoint(x: 0.4, y: 0.2, pressure: 0.5),
|
||||||
|
EditorPoint(x: 0.7, y: 0.15, pressure: 0.2),
|
||||||
|
],
|
||||||
|
width: 0.01,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('buildStrokeOutline traces exactly the shared freehandOutlinePoints', () {
|
||||||
|
final stroke = pen();
|
||||||
|
final pfPoints = stroke.points
|
||||||
|
.map((p) => pf.PointVector(
|
||||||
|
p.x * size.width, p.y * size.height, p.pressure ?? 0.5))
|
||||||
|
.toList();
|
||||||
|
final shared = freehandOutlinePoints(
|
||||||
|
pfPoints: pfPoints,
|
||||||
|
size: stroke.width * size.width,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: true,
|
||||||
|
isComplete: true,
|
||||||
|
);
|
||||||
|
expect(shared, isNotEmpty);
|
||||||
|
|
||||||
|
final ref = Path()..moveTo(shared.first.dx, shared.first.dy);
|
||||||
|
for (var i = 1; i < shared.length; i++) {
|
||||||
|
ref.lineTo(shared[i].dx, shared[i].dy);
|
||||||
|
}
|
||||||
|
ref.close();
|
||||||
|
|
||||||
|
final screen = buildStrokeOutline(stroke, size, isComplete: true);
|
||||||
|
// Same vertices ⇒ identical bounds. (If the screen path used different
|
||||||
|
// options than the shared core, the outline would differ.)
|
||||||
|
expect(screen.getBounds(), ref.getBounds());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default thinning IS kDefaultPenThinning (export passes the default)', () {
|
||||||
|
final pts = [
|
||||||
|
pf.PointVector(10, 10, 0.9),
|
||||||
|
pf.PointVector(100, 40, 0.5),
|
||||||
|
pf.PointVector(200, 30, 0.2),
|
||||||
|
];
|
||||||
|
final byDefault = freehandOutlinePoints(
|
||||||
|
pfPoints: pts,
|
||||||
|
size: 8,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: true,
|
||||||
|
isComplete: true,
|
||||||
|
);
|
||||||
|
final explicit = freehandOutlinePoints(
|
||||||
|
pfPoints: pts,
|
||||||
|
size: 8,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: true,
|
||||||
|
isComplete: true,
|
||||||
|
thinning: kDefaultPenThinning,
|
||||||
|
);
|
||||||
|
expect(byDefault, explicit);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thinning actually changes the outline (param is wired, not ignored)',
|
||||||
|
() {
|
||||||
|
final pts = [
|
||||||
|
pf.PointVector(10, 10, 0.9),
|
||||||
|
pf.PointVector(100, 40, 0.5),
|
||||||
|
pf.PointVector(200, 30, 0.2),
|
||||||
|
];
|
||||||
|
final strong = freehandOutlinePoints(
|
||||||
|
pfPoints: pts,
|
||||||
|
size: 8,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: true,
|
||||||
|
isComplete: true,
|
||||||
|
thinning: kDefaultPenThinning,
|
||||||
|
);
|
||||||
|
final none = freehandOutlinePoints(
|
||||||
|
pfPoints: pts,
|
||||||
|
size: 8,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: true,
|
||||||
|
isComplete: true,
|
||||||
|
thinning: 0.0,
|
||||||
|
);
|
||||||
|
expect(strong, isNot(equals(none)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input yields an empty outline (no crash)', () {
|
||||||
|
expect(
|
||||||
|
freehandOutlinePoints(
|
||||||
|
pfPoints: const [],
|
||||||
|
size: 8,
|
||||||
|
isHighlighter: false,
|
||||||
|
hasRealPressure: false,
|
||||||
|
isComplete: true,
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
73
test/input_arbiter_test.dart
Normal file
73
test/input_arbiter_test.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Truth-table tests for the pure draw-vs-pan arbitration (P0 step 4/8). These
|
||||||
|
// pin the make-or-break gesture rules (palm rejection, finger toggle, hardware
|
||||||
|
// pan button, multi-pointer = pinch) so a refactor of pen_canvas can't silently
|
||||||
|
// change behavior.
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/input/input_arbiter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('isStylusKind', () {
|
||||||
|
test('stylus and invertedStylus are pens; others are not', () {
|
||||||
|
expect(isStylusKind(PointerDeviceKind.stylus), isTrue);
|
||||||
|
expect(isStylusKind(PointerDeviceKind.invertedStylus), isTrue);
|
||||||
|
expect(isStylusKind(PointerDeviceKind.touch), isFalse);
|
||||||
|
expect(isStylusKind(PointerDeviceKind.mouse), isFalse);
|
||||||
|
expect(isStylusKind(PointerDeviceKind.trackpad), isFalse);
|
||||||
|
expect(isStylusKind(PointerDeviceKind.unknown), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('shouldDraw', () {
|
||||||
|
bool draw(
|
||||||
|
int count,
|
||||||
|
PointerDeviceKind kind, {
|
||||||
|
bool finger = false,
|
||||||
|
bool hwPan = false,
|
||||||
|
}) =>
|
||||||
|
shouldDraw(
|
||||||
|
activePointerCount: count,
|
||||||
|
kind: kind,
|
||||||
|
fingerDrawingEnabled: finger,
|
||||||
|
hwPanActive: hwPan,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a single stylus always draws', () {
|
||||||
|
expect(draw(1, PointerDeviceKind.stylus), isTrue);
|
||||||
|
expect(draw(1, PointerDeviceKind.invertedStylus), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single mouse draws (desktop authoring)', () {
|
||||||
|
expect(draw(1, PointerDeviceKind.mouse), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a single finger draws ONLY when finger-drawing is enabled', () {
|
||||||
|
expect(draw(1, PointerDeviceKind.touch, finger: false), isFalse);
|
||||||
|
expect(draw(1, PointerDeviceKind.touch, finger: true), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('>= 2 pointers never draw (pinch owns it), even a stylus', () {
|
||||||
|
expect(draw(2, PointerDeviceKind.stylus), isFalse);
|
||||||
|
expect(draw(2, PointerDeviceKind.touch, finger: true), isFalse);
|
||||||
|
expect(draw(3, PointerDeviceKind.mouse), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zero pointers never draw', () {
|
||||||
|
expect(draw(0, PointerDeviceKind.stylus), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hardware pan button suppresses drawing for any device', () {
|
||||||
|
expect(draw(1, PointerDeviceKind.stylus, hwPan: true), isFalse);
|
||||||
|
expect(draw(1, PointerDeviceKind.mouse, hwPan: true), isFalse);
|
||||||
|
expect(draw(1, PointerDeviceKind.touch, finger: true, hwPan: true),
|
||||||
|
isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trackpad / unknown never draw', () {
|
||||||
|
expect(draw(1, PointerDeviceKind.trackpad, finger: true), isFalse);
|
||||||
|
expect(draw(1, PointerDeviceKind.unknown, finger: true), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
137
test/save_scheduler_test.dart
Normal file
137
test/save_scheduler_test.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Tests for SaveScheduler (P0 step 8): debounce coalescing, synchronous
|
||||||
|
// snapshot capture, per-host independence, flush, and dispose. The scheduler's
|
||||||
|
// value is its batching/timing logic, so we drive it with a recording repo that
|
||||||
|
// records saveHost calls instead of touching the DB.
|
||||||
|
//
|
||||||
|
// Run via:
|
||||||
|
// bash tool/test.sh test/save_scheduler_test.dart
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
// Database + inMemoryDatabasePath are re-exported by sqflite_common_ffi.
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/editor/persistence/editor_repository.dart';
|
||||||
|
import 'package:badnote/editor/persistence/save_scheduler.dart';
|
||||||
|
|
||||||
|
class _RecordingRepo extends EditorRepository {
|
||||||
|
_RecordingRepo(super.db);
|
||||||
|
|
||||||
|
final List<({String kind, String host, List<EditorStroke> strokes})> calls =
|
||||||
|
[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveHost(
|
||||||
|
String hostKind,
|
||||||
|
String hostId,
|
||||||
|
List<EditorStroke> strokes,
|
||||||
|
) async {
|
||||||
|
calls.add((kind: hostKind, host: hostId, strokes: strokes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||||
|
id: id,
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.1, y: 0.2),
|
||||||
|
EditorPoint(x: 0.3, y: 0.4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Database db;
|
||||||
|
late _RecordingRepo repo;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
|
||||||
|
repo = _RecordingRepo(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flush writes pending immediately without waiting for the debounce',
|
||||||
|
() async {
|
||||||
|
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('a')]);
|
||||||
|
expect(repo.calls, isEmpty, reason: 'debounce not elapsed yet');
|
||||||
|
|
||||||
|
await scheduler.flush();
|
||||||
|
|
||||||
|
expect(repo.calls, hasLength(1));
|
||||||
|
expect(repo.calls.single.host, 'h1');
|
||||||
|
expect(repo.calls.single.strokes.single.id, 'a');
|
||||||
|
scheduler.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rapid successive schedules coalesce into ONE write with the latest snapshot',
|
||||||
|
() async {
|
||||||
|
final scheduler =
|
||||||
|
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('v1')]);
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2')]);
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('v1'), _stroke('v2'), _stroke('v3')]);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(repo.calls, hasLength(1), reason: '3 schedules → 1 debounced write');
|
||||||
|
expect(repo.calls.single.strokes.map((s) => s.id),
|
||||||
|
['v1', 'v2', 'v3']);
|
||||||
|
scheduler.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captured snapshot is isolated from later mutation of the source list',
|
||||||
|
() async {
|
||||||
|
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
|
||||||
|
final source = [_stroke('a')];
|
||||||
|
// Caller convention: pass a defensive copy.
|
||||||
|
scheduler.schedule('page', 'h1', List.of(source));
|
||||||
|
// Mutating the source after scheduling must not affect the write.
|
||||||
|
source.add(_stroke('b'));
|
||||||
|
|
||||||
|
await scheduler.flush();
|
||||||
|
|
||||||
|
expect(repo.calls.single.strokes.map((s) => s.id), ['a']);
|
||||||
|
scheduler.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct hosts are scheduled and flushed independently', () async {
|
||||||
|
final scheduler = SaveScheduler(repo, debounce: const Duration(seconds: 30));
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('a')]);
|
||||||
|
scheduler.schedule('page', 'h2', [_stroke('b')]);
|
||||||
|
|
||||||
|
await scheduler.flush();
|
||||||
|
|
||||||
|
expect(repo.calls, hasLength(2));
|
||||||
|
expect(repo.calls.map((c) => c.host).toSet(), {'h1', 'h2'});
|
||||||
|
scheduler.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dispose cancels a pending write (nothing is persisted)', () async {
|
||||||
|
final scheduler =
|
||||||
|
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('a')]);
|
||||||
|
scheduler.dispose();
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(repo.calls, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schedule after dispose is a no-op', () async {
|
||||||
|
final scheduler =
|
||||||
|
SaveScheduler(repo, debounce: const Duration(milliseconds: 20));
|
||||||
|
scheduler.dispose();
|
||||||
|
scheduler.schedule('page', 'h1', [_stroke('a')]);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||||
|
|
||||||
|
expect(repo.calls, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user