feat(editor): M1 pdfrx spike + pen/touch capture
Some checks failed
CI / Windows build (push) Has been cancelled

Add pdfrx 2.4.4. PenCaptureRegion routes stylus to ink (arena-bypass
via PenCaptureBinding) while touch falls through to pdfrx scroll/zoom.
Spike pane hosts PdfViewer with page-overlay ink at normalized coords +
frame-time HUD; reachable from home screen for on-device testing.
Bench/coordinate harness under integration_test. Generated assets are
gitignored (regenerate via tool/gen_*.dart).
This commit is contained in:
2026-06-21 20:15:21 +08:00
parent 2afc126f30
commit ee1b3a39f1
16 changed files with 1878 additions and 2 deletions

4
.gitignore vendored
View File

@@ -56,3 +56,7 @@ server/.omc/
server/data/ server/data/
server/*.db server/*.db
server/.env server/.env
# M1 spike generated bench assets (regenerate via tool/gen_*.dart)
/test/assets/large_300p.pdf
/test/assets/dense_strokes.json

View File

@@ -0,0 +1,166 @@
// integration_test/coordinate_assertion_test.dart
//
// M1 MUST #2 (plan §2.1 / §10): a marker painted at normalized (0.5, 0.5) on a
// PDF page MUST land at the visual page-center pixel across 3 zoom levels (fit,
// 2×, 4×). A wrong coordinate model invalidates the entire ink approach, so
// this is a blocking gate.
//
// RUN (on a device/desktop with a display + working pdfium):
// flutter test integration_test/coordinate_assertion_test.dart
// or, on the Windows tablet via a driver:
// flutter drive --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
//
// HEADLESS-LINUX NOTE: pdfium must render off-screen for the page layout to
// resolve. If pdfium cannot render under the harness on a headless Linux box
// (no GL/surface), this test will time out at `_waitForReady`; that is an
// ENVIRONMENT limitation, not a logic failure — run it on the tablet. The
// assertion logic below is correct and must not be weakened to force a pass.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart' as sf;
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
void main() {
// Standard integration binding. This test drives zoom + reads geometry only;
// it does not inject pen events, so PenCaptureRegion stays transparent
// (currentPointerKind == null → never captures), which is exactly correct
// here. (Custom bindings cannot subclass IntegrationTestWidgetsFlutterBinding,
// which the runner initializes first.)
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
pdfrxFlutterInitialize();
late File pdfFile;
setUpAll(() async {
pdfFile = await _writeTinyPdf();
});
tearDownAll(() async {
if (await pdfFile.exists()) await pdfFile.delete();
});
testWidgets('marker at normalized (0.5,0.5) maps to page center at fit/2x/4x',
(tester) async {
final controller = PdfViewerController();
PdfDocument? readyDoc;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
pdfPath: pdfFile.path,
controller: controller,
onViewerReady: (doc, _) => readyDoc = doc,
),
),
),
);
// Wait for pdfrx to load + lay out the page.
final ready = await _waitForReady(tester, controller);
if (!ready) {
fail(
'pdfrx did not become ready (page layout unavailable). This is almost '
'certainly the headless-Linux pdfium limitation described in the file '
'header — run on the Windows tablet:\n'
' flutter drive --driver=test_driver/integration_test.dart '
'--target=integration_test/coordinate_assertion_test.dart',
);
}
expect(readyDoc, isNotNull);
// The page-center in DOCUMENT space is the layout rect center of page 1.
final pageRect = controller.layout.pageLayouts.first;
final pageCenterDoc = pageRect.center;
Future<void> assertCenterAtCurrentZoom(String label) async {
await tester.pumpAndSettle();
// Project the page-center document point to viewer-local (== screen,
// since the viewer fills the Scaffold body) coordinates.
final localCenter = controller.documentToLocal(pageCenterDoc);
// The painter draws the marker at normalized (0.5,0.5) of the page, i.e.
// exactly pageCenterDoc. So localCenter is where the marker pixel must be.
// Cross-check: globalToDocument(localCenter-as-global) round-trips back to
// the page center within tolerance, proving the coordinate model maps
// normalized→document→screen consistently at this zoom.
final box = tester.renderObject<RenderBox>(
find.byType(SpikeEditorPane),
);
final globalCenter = box.localToGlobal(localCenter);
final roundTripDoc = controller.globalToDocument(globalCenter);
expect(roundTripDoc, isNotNull, reason: '$label: globalToDocument null');
final dx = (roundTripDoc!.dx - pageCenterDoc.dx).abs();
final dy = (roundTripDoc.dy - pageCenterDoc.dy).abs();
// Tolerance: 1 document unit (sub-pixel at these zooms).
expect(dx, lessThan(1.0),
reason: '$label: x off by $dx doc units (zoom=${controller.currentZoom})');
expect(dy, lessThan(1.0),
reason: '$label: y off by $dy doc units (zoom=${controller.currentZoom})');
}
// --- fit ---
await controller.goTo(
controller.calcMatrixForPage(pageNumber: 1, anchor: PdfPageAnchor.all),
duration: Duration.zero,
);
await assertCenterAtCurrentZoom('fit');
final fitZoom = controller.currentZoom;
// --- 2x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 2, duration: Duration.zero);
await assertCenterAtCurrentZoom('2x');
// --- 4x (relative to fit) ---
await controller.setZoom(pageCenterDoc, fitZoom * 4, duration: Duration.zero);
await assertCenterAtCurrentZoom('4x');
});
}
/// Polls until pdfrx reports a laid-out page (controller.isReady + a page rect),
/// or the timeout elapses. Returns whether it became ready.
Future<bool> _waitForReady(
WidgetTester tester,
PdfViewerController controller, {
Duration timeout = const Duration(seconds: 20),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) {
return true;
}
}
return false;
}
/// Writes a tiny single-page A4 PDF (with a faint border so the page box is
/// non-blank) to a temp file using syncfusion_flutter_pdf (already a dependency).
Future<File> _writeTinyPdf() async {
final doc = sf.PdfDocument();
final page = doc.pages.add();
final size = page.getClientSize();
page.graphics.drawRectangle(
pen: sf.PdfPen(sf.PdfColor(0, 0, 0)),
bounds: Rect.fromLTWH(2, 2, size.width - 4, size.height - 4),
);
page.graphics.drawString(
'M1 coord test',
sf.PdfStandardFont(sf.PdfFontFamily.helvetica, 18),
bounds: Rect.fromLTWH(20, 20, size.width - 40, 40),
);
final bytes = await doc.save();
doc.dispose();
final file = File(
'${Directory.systemTemp.path}/badnote_m1_coord_${DateTime.now().microsecondsSinceEpoch}.pdf',
);
await file.writeAsBytes(bytes, flush: true);
return file;
}

View File

@@ -0,0 +1,245 @@
// integration_test/perf_scroll_bench.dart
//
// M1 MUST #4 / MUST #5 harness (plan §7.1 / §10).
//
// MUST #4 — pdfrx alone: fling-scroll the 300-page asset; median frame
// (build+raster) ≤ 16.6ms, p95 ≤ 22ms.
// MUST #5 — WITH dense ink overlay: same scroll with ~300 synthetic
// strokes/page painted into pageOverlaysBuilder; median frame BUILD
// time ≤ 16.6ms.
//
// Sample protocol (§7.1): N ≥ 120 frames during sustained programmatic fling,
// PROFILE mode, warm cache — discard the first 30 frames so tile/Picture caches
// are populated before sampling.
//
// RUN (profile mode, on the Windows tablet or a desktop with a display):
// flutter test --profile integration_test/perf_scroll_bench.dart
// or via the driver for on-device profiling:
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// NOTE: results in `flutter test` (debug/headless) are NOT representative —
// always read the numbers from a PROFILE run on the target device. On a
// headless Linux box pdfium may fail to render; if so the bench prints a clear
// skip and must be run on the tablet (see coordinate_assertion_test header).
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:badnote/editor/pdf/spike_editor_pane.dart';
const String _kPdfPath = 'test/assets/large_300p.pdf';
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
/// Frames to sample after warm-up.
const int _kSampleFrames = 120;
/// Frames to discard before sampling (cache warm-up, §7.1).
const int _kWarmupFrames = 30;
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Report raw frame timings to the device lab / driver too.
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
pdfrxFlutterInitialize();
testWidgets('MUST #4/#5 fling-scroll frame-timing bench', (tester) async {
final pdf = File(_kPdfPath);
if (!pdf.existsSync()) {
stdout.writeln('SKIP: $_kPdfPath not found — run tool/gen_bench_pdf.dart.');
return;
}
// ---- MUST #4: pdfrx alone ----
final r4 = await _runScrollPass(
tester,
label: 'MUST #4 — pdfrx alone (no ink overlay)',
inkLoad: false,
);
// ---- MUST #5: WITH dense ink overlay ----
final r5 = await _runScrollPass(
tester,
label: 'MUST #5 — WITH dense ink overlay (~300 strokes/page)',
inkLoad: true,
);
if (r4 == null || r5 == null) {
stdout.writeln(
'\n=== PERF BENCH SKIPPED ===\n'
'pdfrx did not become ready (headless pdfium limitation). Run on the '
'Windows tablet in profile mode:\n'
' flutter drive --profile '
'--driver=test_driver/integration_test.dart '
'--target=integration_test/perf_scroll_bench.dart\n',
);
return;
}
_printReport('MUST #4', r4, buildOnlyGate: false);
_printReport('MUST #5', r5, buildOnlyGate: true);
});
}
class _Stats {
_Stats(this.label, this.build, this.raster, this.total);
final String label;
final _Series build;
final _Series raster;
final _Series total;
}
class _Series {
_Series(List<double> values)
: median = _pct(values, 50),
p95 = _pct(values, 95),
worst = values.isEmpty ? 0 : (List<double>.from(values)..sort()).last,
jankFrames = values.where((v) => v > 32.0).length,
n = values.length;
final double median;
final double p95;
final double worst;
final int jankFrames;
final int n;
static double _pct(List<double> v, int p) {
if (v.isEmpty) return 0;
final s = List<double>.from(v)..sort();
final i = ((p / 100.0) * (s.length - 1)).round();
return s[i.clamp(0, s.length - 1)];
}
}
/// Pumps the spike pane, warms up, then drives a sustained fling while
/// collecting FrameTiming. Returns null if pdfrx never became ready.
Future<_Stats?> _runScrollPass(
WidgetTester tester, {
required String label,
required bool inkLoad,
}) async {
final controller = PdfViewerController();
final paneKey = GlobalKey<SpikeEditorPaneState>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpikeEditorPane(
key: paneKey,
pdfPath: _kPdfPath,
controller: controller,
denseStrokesAsset: _kDenseStrokesAsset,
),
),
),
);
// Wait for the document to lay out.
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 100));
if (controller.isReady && controller.layout.pageLayouts.isNotEmpty) break;
}
if (!controller.isReady || controller.layout.pageLayouts.isEmpty) {
return null;
}
if (inkLoad) {
await paneKey.currentState!.setInkLoad(true);
await tester.pumpAndSettle();
}
// Collect frame timings.
final build = <double>[];
final raster = <double>[];
final total = <double>[];
var seen = 0;
void onTimings(List<FrameTiming> timings) {
for (final t in timings) {
seen++;
if (seen <= _kWarmupFrames) continue; // discard warm-up (§7.1)
if (build.length >= _kSampleFrames) continue;
build.add(t.buildDuration.inMicroseconds / 1000.0);
raster.add(t.rasterDuration.inMicroseconds / 1000.0);
total.add(t.totalSpan.inMicroseconds / 1000.0);
}
}
SchedulerBinding.instance.addTimingsCallback(onTimings);
try {
// Sustained fling: repeated downward flings across the viewport center to
// keep the document scrolling continuously while we gather ≥150 frames.
final center = tester.getCenter(find.byType(SpikeEditorPane));
var safety = 0;
while (build.length < _kSampleFrames && safety < 400) {
await tester.fling(
find.byType(SpikeEditorPane),
const Offset(0, -600),
2000,
warnIfMissed: false,
);
// Pump several frames to let the fling settle and emit timings.
for (var i = 0; i < 20 && build.length < _kSampleFrames; i++) {
await tester.pump(const Duration(milliseconds: 16));
}
// Nudge back up occasionally so we don't run off the end of 300 pages.
if (safety % 8 == 7) {
await tester.fling(find.byType(SpikeEditorPane),
const Offset(0, 1200), 2000, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 16));
}
safety++;
// Keep `center` referenced (avoids unused warning) and re-target if needed.
if (!tester.binding.hasScheduledFrame && center.dy < 0) break;
}
} finally {
SchedulerBinding.instance.removeTimingsCallback(onTimings);
}
return _Stats(
label,
_Series(build),
_Series(raster),
_Series(total),
);
}
void _printReport(String tag, _Stats s, {required bool buildOnlyGate}) {
final buf = StringBuffer();
buf.writeln('\n========================================================');
buf.writeln('$tag${s.label}');
buf.writeln('Protocol (§7.1): profile mode, warm cache, '
'discarded first $_kWarmupFrames frames, sampled ${s.build.n} frames.');
buf.writeln('--------------------------------------------------------');
buf.writeln('phase median p95 worst jank(>32ms)');
buf.writeln('build ${_row(s.build)}');
buf.writeln('raster ${_row(s.raster)}');
buf.writeln('total ${_row(s.total)}');
buf.writeln('--------------------------------------------------------');
if (buildOnlyGate) {
final pass = s.build.median <= 16.6;
buf.writeln('GATE (MUST #5): build median ${s.build.median.toStringAsFixed(2)}ms '
'≤ 16.6ms -> ${pass ? "PASS" : "FAIL"}');
} else {
final passMed = s.total.median <= 16.6;
final passP95 = s.total.p95 <= 22.0;
buf.writeln('GATE (MUST #4): build+raster median '
'${s.total.median.toStringAsFixed(2)}ms ≤ 16.6ms -> '
'${passMed ? "PASS" : "FAIL"}; '
'p95 ${s.total.p95.toStringAsFixed(2)}ms ≤ 22ms -> '
'${passP95 ? "PASS" : "FAIL"}');
}
buf.writeln('========================================================\n');
stdout.write(buf.toString());
}
String _row(_Series s) =>
'${s.median.toStringAsFixed(2).padLeft(7)}ms '
'${s.p95.toStringAsFixed(2).padLeft(6)}ms '
'${s.worst.toStringAsFixed(2).padLeft(6)}ms '
'${s.jankFrames.toString().padLeft(6)}';

View File

@@ -0,0 +1,198 @@
// lib/editor/pdf/pen_capture_region.dart
//
// Per-pointer-kind input transport for the BadNote PDF editor (plan §3.5).
//
// PROBLEM
// -------
// pdfrx's PdfViewer uses an internal InteractiveViewer-style pan/zoom driven by
// a GestureDetector (onScaleStart/Update/End). A widget layered on top of the
// viewer that wants to *draw* with the pen would normally compete with that
// recognizer in Flutter's **gesture arena** — and the scale recognizer is
// greedy, so a freehand pen drag would frequently be claimed by the viewer
// (pen → pan) or starve touch scrolling. pdfrx's own
// `PdfOverlayInteractionRegion` is tap-oriented only and gives us no freehand
// drag stream that bypasses the arena.
//
// SOLUTION (arena-bypass, per-kind hit-test split)
// ------------------------------------------------
// We do NOT use a GestureDetector for pen capture. Instead we install a custom
// RenderProxyBox (`_RenderPenCapture`) whose hit-test answer is conditioned on
// the pointer device kind of the pointer-DOWN currently being routed:
//
// * stylus / invertedStylus -> hitTest returns TRUE -> this box becomes the
// pointer's hit-test target and receives the
// entire down/move/up stream directly via
// RenderObject.handleEvent, NOT through the
// gesture arena (we never add a recognizer).
//
// * touch / mouse / trackpad -> hitTest returns FALSE -> the hit-test
// continues past us to the pdfrx viewer
// underneath, so single-finger touch scroll and
// pinch-zoom reach pdfrx untouched.
//
// WHY A CUSTOM BINDING IS REQUIRED (the only correct seam)
// --------------------------------------------------------
// Flutter only hit-tests a pointer on its PointerDownEvent, then CACHES the
// resulting HitTestResult and reuses it for every subsequent move/up
// (GestureBinding._handlePointerEventImmediately). Crucially,
// `hitTestInView(result, position, viewId)` is passed only the *position*, NOT
// the event — so a RenderBox.hitTest cannot read the pointer's kind from its
// arguments or from the HitTestResult. `pointerRouter.addGlobalRoute` fires
// during dispatchEvent, which is AFTER hit-test, so it is too late to influence
// the DOWN routing.
//
// The single reliable, fully-public seam is `GestureBinding.handlePointerEvent`
// (virtual), which is called with the live event immediately before the
// synchronous hit-test of that same event. [PenCaptureBinding] overrides it to
// stash the in-flight pointer kind into [PenCaptureBinding.currentPointerKind]
// before delegating to super; `_RenderPenCapture.hitTest` reads that field. The
// app must install [PenCaptureBinding] in main() (see spike_main.dart). If a
// non-PenCapture binding is in use, [currentPointerKind] stays null and the
// region defaults to NOT capturing — so touch scrolling is never accidentally
// stolen.
//
// The `captureEnabled` flag gates capture by editor mode (plan §6.1: Browse
// never captures pen; Draw/Type do). When disabled the box is hit-test
// transparent for all kinds.
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Pointer kinds this region captures (plan §3.5 / §6.2: pen only; mouse is
/// never captured for drawing — pen required).
const Set<PointerDeviceKind> kPenCaptureKinds = {
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
/// A [WidgetsFlutterBinding] that records the device kind of the pointer event
/// currently being routed, so that kind-gated hit-testing
/// ([PenCaptureRegion]) can consult it during the synchronous DOWN hit-test.
///
/// Install in main():
/// ```dart
/// void main() {
/// PenCaptureBinding.ensureInitialized();
/// pdfrxFlutterInitialize();
/// runApp(const SpikeApp());
/// }
/// ```
class PenCaptureBinding extends WidgetsFlutterBinding {
/// The device kind of the pointer event currently being handled by
/// [handlePointerEvent], valid for the duration of the synchronous hit-test
/// the framework performs for that event. Null when no binding override is
/// active or between events.
static PointerDeviceKind? currentPointerKind;
/// Ensures a [PenCaptureBinding] is the active binding and returns it.
static WidgetsBinding ensureInitialized() {
PenCaptureBinding();
return WidgetsBinding.instance;
}
@override
void handlePointerEvent(PointerEvent event) {
// Set the kind BEFORE super performs the synchronous hit-test for a DOWN.
currentPointerKind = event.kind;
try {
super.handlePointerEvent(event);
} finally {
// Leave currentPointerKind set to the last event's kind; it is only read
// transiently during hit-test (which happens inside super for DOWNs).
// We do not null it here because moves reuse the cached hit path and never
// re-hit-test, so staleness between events is harmless.
}
}
}
/// A transparent capture region that forwards the full pointer stream for
/// stylus/invertedStylus pointers to [onPenEvent] while letting touch and mouse
/// pointers fall through to whatever is painted underneath (typically a pdfrx
/// `PdfViewer`).
///
/// See the file header for the arena-bypass rationale and binding requirement.
class PenCaptureRegion extends SingleChildRenderObjectWidget {
const PenCaptureRegion({
super.key,
required this.onPenEvent,
this.captureEnabled = true,
required Widget child,
}) : super(child: child);
/// Called with every [PointerEvent] (down/move/up/cancel) of a captured pen
/// pointer, delivered raw and in order without arena arbitration.
final void Function(PointerEvent event) onPenEvent;
/// When false the region is hit-test transparent for all pointer kinds, so
/// even pen events fall through to the viewer underneath (e.g. Browse mode).
final bool captureEnabled;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderPenCapture(
onPenEvent: onPenEvent,
captureEnabled: captureEnabled,
);
}
@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _RenderPenCapture)
..onPenEvent = onPenEvent
..captureEnabled = captureEnabled;
}
}
/// RenderProxyBox that conditionally participates in hit-testing based on the
/// in-flight pointer's device kind (via [PenCaptureBinding.currentPointerKind])
/// and forwards captured pen events.
class _RenderPenCapture extends RenderProxyBox {
_RenderPenCapture({
required void Function(PointerEvent) onPenEvent,
required bool captureEnabled,
}) : _onPenEvent = onPenEvent,
_captureEnabled = captureEnabled;
void Function(PointerEvent) _onPenEvent;
set onPenEvent(void Function(PointerEvent) value) => _onPenEvent = value;
bool _captureEnabled;
set captureEnabled(bool value) => _captureEnabled = value;
/// Returns true only for pen kinds while capture is enabled. Returning false
/// continues the hit-test to the pdfrx viewer underneath, which is how
/// touch/mouse reach it for scroll/zoom.
///
/// We override [hitTest] directly (rather than relying on
/// [hitTestChildren]+[hitTestSelf]) so the kind check is the single decision
/// point and covers the entire box area, regardless of the translucent paint
/// child.
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!_captureEnabled) return false;
if (!_shouldCaptureCurrentPointer()) return false;
if (!size.contains(position)) return false;
result.add(BoxHitTestEntry(this, position));
return true;
}
/// Mirrors the predicate for any caller routing through the default
/// RenderBox.hitTest dispatch (hitTestChildren then hitTestSelf).
@override
bool hitTestSelf(Offset position) =>
_captureEnabled && _shouldCaptureCurrentPointer();
bool _shouldCaptureCurrentPointer() {
final kind = PenCaptureBinding.currentPointerKind;
if (kind == null) return false; // No kind-aware binding → never steal touch.
return kPenCaptureKinds.contains(kind);
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (!_captureEnabled) return;
if (!kPenCaptureKinds.contains(event.kind)) return;
_onPenEvent(event);
}
}

View File

@@ -0,0 +1,187 @@
// lib/editor/pdf/spike_app.dart
//
// THROWAWAY M1 spike app shell (plan §10). Wraps [SpikeEditorPane] with an
// on-screen frame-timing HUD (median build & raster ms over the last ~120
// frames) and an ink-load toggle, so MUST #4/#5 are observable on-device when
// launched via `flutter run -t lib/editor/pdf/spike_main.dart` on the tablet.
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:pdfrx/pdfrx.dart';
import 'spike_editor_pane.dart';
class SpikeApp extends StatelessWidget {
const SpikeApp({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BadNote M1 Spike',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
home: SpikeHome(
pdfPath: pdfPath,
denseStrokesAsset: denseStrokesAsset,
),
);
}
}
class SpikeHome extends StatefulWidget {
const SpikeHome({super.key, required this.pdfPath, this.denseStrokesAsset});
final String pdfPath;
final String? denseStrokesAsset;
@override
State<SpikeHome> createState() => _SpikeHomeState();
}
class _SpikeHomeState extends State<SpikeHome> {
final GlobalKey<SpikeEditorPaneState> _paneKey =
GlobalKey<SpikeEditorPaneState>();
final PdfViewerController _controller = PdfViewerController();
bool _inkLoad = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
SpikeEditorPane(
key: _paneKey,
controller: _controller,
pdfPath: widget.pdfPath,
denseStrokesAsset: widget.denseStrokesAsset,
),
const Positioned(top: 8, left: 8, child: FrameTimingHud()),
],
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton.extended(
heroTag: 'inkload',
onPressed: () async {
final next = !_inkLoad;
await _paneKey.currentState?.setInkLoad(next);
setState(() => _inkLoad = next);
},
label: Text(_inkLoad ? 'Ink load: ON' : 'Ink load: OFF'),
icon: const Icon(Icons.brush),
),
],
),
);
}
}
/// On-screen median build/raster frame-time HUD, driven by
/// [SchedulerBinding.addTimingsCallback]. Shows the median of the last
/// [_window] frames for both the build (`buildDuration`) and raster
/// (`rasterDuration`) phases — the two halves of the 16.6ms budget tracked by
/// MUST #4/#5.
class FrameTimingHud extends StatefulWidget {
const FrameTimingHud({super.key});
@override
State<FrameTimingHud> createState() => _FrameTimingHudState();
}
class _FrameTimingHudState extends State<FrameTimingHud> {
static const int _window = 120;
final List<double> _build = <double>[];
final List<double> _raster = <double>[];
double _medBuild = 0;
double _medRaster = 0;
double _p95Build = 0;
double _p95Raster = 0;
@override
void initState() {
super.initState();
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
@override
void dispose() {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
super.dispose();
}
void _onTimings(List<FrameTiming> timings) {
for (final t in timings) {
_build.add(t.buildDuration.inMicroseconds / 1000.0);
_raster.add(t.rasterDuration.inMicroseconds / 1000.0);
}
while (_build.length > _window) {
_build.removeAt(0);
}
while (_raster.length > _window) {
_raster.removeAt(0);
}
if (!mounted) return;
setState(() {
_medBuild = _percentile(_build, 50);
_medRaster = _percentile(_raster, 50);
_p95Build = _percentile(_build, 95);
_p95Raster = _percentile(_raster, 95);
});
}
static double _percentile(List<double> values, int p) {
if (values.isEmpty) return 0;
final sorted = List<double>.from(values)..sort();
final idx = ((p / 100.0) * (sorted.length - 1)).round();
return sorted[idx.clamp(0, sorted.length - 1)];
}
@override
Widget build(BuildContext context) {
Color budget(double ms) => ms <= 16.6
? Colors.greenAccent
: (ms <= 22 ? Colors.amberAccent : Colors.redAccent);
return IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(8),
),
child: DefaultTextStyle(
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Colors.white,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('frames: ${_build.length}/$_window'),
Text.rich(TextSpan(children: [
const TextSpan(text: 'build med '),
TextSpan(
text: '${_medBuild.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medBuild))),
TextSpan(text: ' p95 ${_p95Build.toStringAsFixed(1)}ms'),
])),
Text.rich(TextSpan(children: [
const TextSpan(text: 'raster med '),
TextSpan(
text: '${_medRaster.toStringAsFixed(1)}ms',
style: TextStyle(color: budget(_medRaster))),
TextSpan(text: ' p95 ${_p95Raster.toStringAsFixed(1)}ms'),
])),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,344 @@
// lib/editor/pdf/spike_editor_pane.dart
//
// THROWAWAY M1 spike widget (plan §10 / MUST #2, #4, #5). Hosts a pdfrx
// PdfViewer.file and exercises the three things the M1 gate must prove:
//
// 1. Coordinate correctness (MUST #2): a `pageOverlaysBuilder` paints a
// diagnostic crosshair at normalized (0.5, 0.5) using
// `canvas.scale(size.width, size.height)`, with the CustomPaint sized to
// `pageRect.size` (plan §2.1). This dot MUST sit at the visual page center
// at every zoom level. `coordinate_assertion_test.dart` asserts this.
//
// 2. Pen/touch arbitration (MUST #3): a `viewerOverlayBuilder` wraps a
// `PenCaptureRegion` so pen events draw a live viewer-level stroke while
// touch scrolls and pinch zooms — same overlay, no mode switch.
//
// 3. Ink-overlay build cost (MUST #5): a toggle injects ~N synthetic strokes
// per page (from dense_strokes.json) into the page overlay so the perf
// bench can measure BUILD time with a non-trivial ui.Picture per page.
//
// This file is NOT production code and is excluded from the real editor.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
/// Normalized page-space point the diagnostic marker is painted at. The M1
/// coordinate assertion checks this maps to the page-center pixel at all zooms.
const Offset kMarkerNormalized = Offset(0.5, 0.5);
/// A single captured pen sample in normalized page space, tagged with its page.
class _PenSample {
const _PenSample(this.pageIndex, this.normalized);
final int pageIndex;
final Offset normalized;
}
/// Spike editor pane. Provide a [pdfPath] to a local PDF (e.g.
/// test/assets/large_300p.pdf). [denseStrokesAsset] is a filesystem PATH to the
/// synthetic ink load (MUST #5); if null the ink-load toggle is inert.
class SpikeEditorPane extends StatefulWidget {
const SpikeEditorPane({
super.key,
required this.pdfPath,
this.denseStrokesAsset,
this.strokesPerPage = 300,
this.strokeCountKey = '2000',
this.onViewerReady,
this.controller,
});
final String pdfPath;
final String? denseStrokesAsset;
final int strokesPerPage;
/// Which top-level array in dense_strokes.json to draw from ("2000"/"5000").
final String strokeCountKey;
/// Forwarded from pdfrx once the document is laid out and interactive.
final void Function(PdfDocument document, PdfViewerController controller)?
onViewerReady;
/// Optional externally-owned controller (tests drive zoom through this).
final PdfViewerController? controller;
@override
State<SpikeEditorPane> createState() => SpikeEditorPaneState();
}
class SpikeEditorPaneState extends State<SpikeEditorPane> {
late final PdfViewerController _controller =
widget.controller ?? PdfViewerController();
/// Live pen strokes captured via PenCaptureRegion (viewer-level overlay).
final List<List<_PenSample>> _penStrokes = <List<_PenSample>>[];
List<_PenSample>? _activeStroke;
/// Synthetic strokes for the ink-load gate, lazily loaded. Each entry is a
/// list of normalized polylines (one stroke = list of points).
List<List<Offset>>? _syntheticStrokes;
bool _inkLoadEnabled = false;
bool _loadingSynthetic = false;
bool get inkLoadEnabled => _inkLoadEnabled;
/// Toggle the dense synthetic-ink overlay (MUST #5). Loads the asset on first
/// enable. Public so the perf bench can drive it programmatically.
Future<void> setInkLoad(bool enabled) async {
if (enabled && _syntheticStrokes == null) {
await _loadSyntheticStrokes();
}
if (mounted) setState(() => _inkLoadEnabled = enabled);
}
Future<void> _loadSyntheticStrokes() async {
final asset = widget.denseStrokesAsset;
if (asset == null || _loadingSynthetic) return;
_loadingSynthetic = true;
try {
// [asset] is a filesystem path (e.g. test/assets/dense_strokes.json),
// not a bundled rootBundle key — regenerate via tool/gen_dense_strokes.dart.
final raw = await File(asset).readAsString();
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final strokesJson =
(decoded[widget.strokeCountKey] as List<dynamic>? ?? const []);
final result = <List<Offset>>[];
for (final s in strokesJson) {
final points = (s as Map<String, dynamic>)['points'] as List<dynamic>;
final poly = <Offset>[];
for (final p in points) {
final pt = p as Map<String, dynamic>;
poly.add(Offset(
(pt['x'] as num).toDouble(),
(pt['y'] as num).toDouble(),
));
}
if (poly.length >= 2) result.add(poly);
}
_syntheticStrokes = result;
} finally {
_loadingSynthetic = false;
}
}
// --- Pen capture (viewer-level) ---------------------------------------
void _onPenEvent(PointerEvent event) {
// Convert global → document → which page + normalized page coords.
final doc = _controller.globalToDocument(event.position);
if (doc == null) return;
final hit = _documentToPage(doc);
if (hit == null) return;
if (event is PointerDownEvent) {
_activeStroke = <_PenSample>[hit];
_penStrokes.add(_activeStroke!);
setState(() {});
} else if (event is PointerMoveEvent) {
_activeStroke?.add(hit);
setState(() {});
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_activeStroke = null;
}
}
/// Maps a document-space point to (pageIndex, normalized-in-page) using the
/// controller's page layout rects (document coordinates). Returns null if the
/// point is outside every page box.
_PenSample? _documentToPage(Offset doc) {
if (!_controller.isReady) return null;
final rects = _controller.layout.pageLayouts;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return _PenSample(i, Offset(nx, ny));
}
}
return null;
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
PdfViewer.file(
widget.pdfPath,
controller: _controller,
params: PdfViewerParams(
onViewerReady: widget.onViewerReady,
// (1) Per-page overlay: diagnostic center marker + optional synthetic
// ink. CustomPaint is sized to pageRect.size so canvas.scale maps
// normalized [0,1] → zoomed pixels (plan §2.1).
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
return [
SizedBox.fromSize(
size: pageRectInViewer.size,
child: CustomPaint(
painter: _SpikeInkPainter(
synthetic:
_inkLoadEnabled ? _strokesForPage(pageIndex) : null,
),
),
),
];
},
// (2) Viewer-level overlay: pen capture + live pen rendering. Touch
// falls through to pdfrx for scroll/zoom (per-kind hit-test split).
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: PenCaptureRegion(
onPenEvent: _onPenEvent,
child: IgnorePointer(
child: CustomPaint(
size: size,
painter: _LivePenPainter(
strokes: _penStrokes,
controller: _controller,
),
),
),
),
),
];
},
),
),
],
);
}
/// Deterministic per-page slice of the synthetic stroke pool so each page
/// shows ~[widget.strokesPerPage] strokes without loading 300× the data.
List<List<Offset>> _strokesForPage(int pageIndex) {
final pool = _syntheticStrokes;
if (pool == null || pool.isEmpty) return const [];
final n = widget.strokesPerPage.clamp(0, pool.length);
final start = (pageIndex * n) % pool.length;
final out = <List<Offset>>[];
for (var i = 0; i < n; i++) {
out.add(pool[(start + i) % pool.length]);
}
return out;
}
// Note: PdfViewerController is not a Listenable/ChangeNotifier we own a
// lifecycle for; pdfrx attaches/detaches it via the PdfViewer. No dispose().
}
/// Paints the diagnostic center marker (always) plus synthetic ink (when the
/// MUST #5 load is enabled), in normalized [0,1] page space scaled to the
/// CustomPaint size (== zoomed page box). This is what the coordinate assertion
/// inspects.
class _SpikeInkPainter extends CustomPainter {
_SpikeInkPainter({this.synthetic});
final List<List<Offset>>? synthetic;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
// Map normalized [0,1] → zoomed pixels (plan §2.1).
canvas.scale(size.width, size.height);
// Synthetic ink load (MUST #5): a non-trivial set of polylines per page.
final syn = synthetic;
if (syn != null && syn.isNotEmpty) {
final inkPaint = Paint()
..color = const Color(0x5500AAFF)
..style = PaintingStyle.stroke
// Stroke width is in normalized units post-scale; keep it page-relative
// and hairline-ish so 300 strokes are visible but cheap.
..strokeWidth = 0.002
..strokeCap = StrokeCap.round;
for (final poly in syn) {
if (poly.length < 2) continue;
final path = Path()..moveTo(poly.first.dx, poly.first.dy);
for (var i = 1; i < poly.length; i++) {
path.lineTo(poly[i].dx, poly[i].dy);
}
canvas.drawPath(path, inkPaint);
}
}
canvas.restore();
// Diagnostic crosshair at normalized (0.5,0.5) — drawn in PIXEL space (after
// restore) so its line thickness is constant on screen and its CENTER is at
// exactly size.width*0.5, size.height*0.5. The coordinate assertion checks
// this pixel.
final center = Offset(
size.width * kMarkerNormalized.dx,
size.height * kMarkerNormalized.dy,
);
final markerPaint = Paint()
..color = const Color(0xFFFF0066)
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
const arm = 16.0;
canvas.drawLine(
center.translate(-arm, 0), center.translate(arm, 0), markerPaint);
canvas.drawLine(
center.translate(0, -arm), center.translate(0, arm), markerPaint);
canvas.drawCircle(center, 3.0, Paint()..color = const Color(0xFFFF0066));
}
@override
bool shouldRepaint(covariant _SpikeInkPainter oldDelegate) =>
oldDelegate.synthetic != synthetic;
}
/// Paints live pen strokes captured by the PenCaptureRegion. Strokes are stored
/// in normalized page space, so for each sample we re-project page→document→
/// local each paint via the controller (keeps strokes glued to pages under
/// scroll/zoom — the §2.1 property, exercised at the viewer level here).
class _LivePenPainter extends CustomPainter {
_LivePenPainter({required this.strokes, required this.controller})
: super(repaint: controller);
final List<List<_PenSample>> strokes;
final PdfViewerController controller;
@override
void paint(Canvas canvas, Size size) {
if (!controller.isReady) return;
final rects = controller.layout.pageLayouts;
final paint = Paint()
..color = const Color(0xFF1565C0)
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final stroke in strokes) {
Path? path;
for (final s in stroke) {
if (s.pageIndex >= rects.length) continue;
final r = rects[s.pageIndex];
// normalized page → document
final docPt = Offset(
r.left + s.normalized.dx * r.width,
r.top + s.normalized.dy * r.height,
);
// document → local (viewer) coords
final local = controller.documentToLocal(docPt);
if (path == null) {
path = Path()..moveTo(local.dx, local.dy);
} else {
path.lineTo(local.dx, local.dy);
}
}
if (path != null) canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _LivePenPainter oldDelegate) => true;
}

View File

@@ -0,0 +1,34 @@
// lib/editor/pdf/spike_launcher.dart
//
// THROWAWAY M1 entry: lets the user open the pdfrx pen/perf spike from the
// running app (so the CI-built Windows package can exercise MUST #3/#4/#5 on a
// real Surface Pen with the user's OWN large PDFs). Remove together with the
// rest of lib/editor/pdf/spike_* once M1 is signed off.
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'spike_editor_pane.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.
Future<void> openM1Spike(BuildContext context) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
final path = result?.files.single.path;
if (path == null) return;
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),
),
),
);
}

View File

@@ -0,0 +1,62 @@
// lib/editor/pdf/spike_main.dart
//
// Standalone entry point for the THROWAWAY M1 pdfrx spike (plan §10).
//
// Launch on the Windows tablet (or any desktop with a display):
// flutter run -t lib/editor/pdf/spike_main.dart
//
// It opens test/assets/large_300p.pdf in [SpikeEditorPane] with the
// frame-timing HUD and ink-load toggle, so the M1 perf/pen gates are
// observable on-device.
//
// IMPORTANT: pen capture requires the kind-aware [PenCaptureBinding] (installed
// below before pdfrx init). pdfrx itself is initialized via
// pdfrxFlutterInitialize() — confirmed from pdfrx 2.4.4 example/pdf_combine.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'pen_capture_region.dart';
import 'spike_app.dart';
/// Default benchmark asset (300-page PDF generated by tool/gen_bench_pdf.dart).
const String _kDefaultPdfRelPath = 'test/assets/large_300p.pdf';
/// Filesystem path for the synthetic ink load (regenerate via
/// tool/gen_dense_strokes.dart; not bundled — read from disk at the project root).
const String _kDenseStrokesAsset = 'test/assets/dense_strokes.json';
void main(List<String> args) {
// Kind-aware binding MUST be installed before runApp so PenCaptureRegion can
// gate hit-testing by pointer kind (see pen_capture_region.dart header).
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (pdfrx 2.4.4 example pattern).
pdfrxFlutterInitialize();
// Allow overriding the PDF path as the first CLI arg (otherwise the default
// 300-page bench asset relative to the project root / cwd).
final pdfPath = args.isNotEmpty ? args.first : _resolvePdfPath();
runApp(
SpikeApp(
pdfPath: pdfPath,
denseStrokesAsset: _kDenseStrokesAsset,
),
);
}
/// Resolve the bench PDF path. `flutter run` sets cwd to the project root, so
/// the relative asset path works on desktop; we also try a couple of fallbacks.
String _resolvePdfPath() {
final candidates = <String>[
_kDefaultPdfRelPath,
'${Directory.current.path}/$_kDefaultPdfRelPath',
];
for (final c in candidates) {
if (File(c).existsSync()) return c;
}
// Return the primary path anyway; pdfrx will surface a clear load error.
return _kDefaultPdfRelPath;
}

View File

@@ -1,14 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'services/database_service.dart'; import 'services/database_service.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // Kind-aware binding (extends WidgetsFlutterBinding) must be the active
// binding before runApp so the M1 spike's PenCaptureRegion can gate
// hit-testing by pointer kind. Safe for the rest of the app: with no pen
// region mounted it behaves exactly like the default binding.
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (required before any PdfViewer is built).
pdfrxFlutterInitialize();
// Ensure DB is ready before the app starts so providers can use it eagerly // Ensure DB is ready before the app starts so providers can use it eagerly
await DatabaseService.getInstance(); await DatabaseService.getInstance();

View File

@@ -5,6 +5,7 @@ import '../models/note.dart';
import '../providers/document_provider.dart'; import '../providers/document_provider.dart';
import '../providers/note_provider.dart'; import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart'; import '../providers/ocr_provider.dart';
import '../editor/pdf/spike_launcher.dart';
import '../services/pdf_service.dart'; import '../services/pdf_service.dart';
import '../services/pptx_service.dart'; import '../services/pptx_service.dart';
import 'note_editor_screen.dart'; import 'note_editor_screen.dart';
@@ -67,6 +68,12 @@ class HomeScreen extends ConsumerWidget {
).push(MaterialPageRoute(builder: (_) => const SearchScreen())); ).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
}, },
), ),
// THROWAWAY M1 spike entry (remove with lib/editor/pdf/spike_*).
IconButton(
icon: const Icon(Icons.science_outlined),
tooltip: 'M1 Spike (pen/scroll/zoom test)',
onPressed: () => openM1Spike(context),
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(

View File

@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.4" version: "0.13.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -318,6 +326,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -384,6 +397,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -440,6 +458,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
image_picker: image_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -504,6 +530,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: transitive dependency: transitive
description: description:
@@ -712,6 +743,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdfium_dart:
dependency: transitive
description:
name: pdfium_dart
sha256: "86e95c66b09f3245b95c4924f2edce8d4f7c9786876e5c5ee8e36b104a94bbb0"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
pdfium_flutter:
dependency: transitive
description:
name: pdfium_flutter
sha256: "420ba8e7673b54da387ceeeb18a72c8bc6e4452128dbb391dca900c748f9e9ba"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
pdfrx:
dependency: "direct main"
description:
name: pdfrx
sha256: e0ca318004c3f32144db8e74fa612abb80ebe79004677293af74ed9af119f47f
url: "https://pub.dev"
source: hosted
version: "2.4.4"
pdfrx_engine:
dependency: transitive
description:
name: pdfrx_engine
sha256: "89865e158ced818690ab207a13a34a65803cd67ee1bec9f5f87838eb5a79600b"
url: "https://pub.dev"
source: hosted
version: "0.4.3"
perfect_freehand: perfect_freehand:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -752,6 +815,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -808,6 +887,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.4" version: "2.6.4"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1005,6 +1092,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
syncfusion_flutter_core: syncfusion_flutter_core:
dependency: transitive dependency: transitive
description: description:
@@ -1229,6 +1324,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1271,4 +1374,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.8 <4.0.0" dart: ">=3.10.8 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.41.0"

View File

@@ -50,6 +50,7 @@ dependencies:
# Embedded ONNX runtime (local OCR recognition backend) # Embedded ONNX runtime (local OCR recognition backend)
flutter_onnxruntime: ^1.8.0 flutter_onnxruntime: ^1.8.0
pdfrx: ^2.4.4
# Pin sqlite3 to the exact version whose native binaries are vendored under # 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 # vendor/sqlite3/ (see hooks block below). Without this, pub re-resolves to the
@@ -61,6 +62,9 @@ dependency_overrides:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# M1 spike perf/coordinate gates (integration_test harness).
integration_test:
sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
# Code Generation # Code Generation

View File

@@ -0,0 +1,16 @@
// test_driver/integration_test.dart
//
// Driver entry point for running the M1 integration_test gates on a real device
// (Windows tablet) in profile mode, e.g.:
//
// flutter drive --profile \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/perf_scroll_bench.dart
//
// flutter drive \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/coordinate_assertion_test.dart
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();

239
tool/gen_bench_pdf.dart Normal file
View File

@@ -0,0 +1,239 @@
// tool/gen_bench_pdf.dart
//
// Generates a benchmark PDF at test/assets/large_300p.pdf using
// package:syncfusion_flutter_pdf.
//
// syncfusion_flutter_pdf imports dart:ui which is only available inside the
// Flutter SDK runtime, so this file CANNOT be run via plain `dart run`.
//
// USAGE:
// flutter test tool/gen_bench_pdf.dart
// flutter test tool/gen_bench_pdf.dart --dart-define=PAGE_COUNT=50
//
// The script is structured as a flutter_test file (one `test(...)` block) so
// that `flutter test` invokes it with the full Flutter engine (dart:ui present).
// It is NOT a real unit test — it is a code-generation tool that happens to
// need the Flutter runtime. The test "passes" as long as the file is written
// successfully.
//
// Each page contains:
// - A bold title (page number heading)
// - Two paragraphs of body text
// - A ruled grid of lines (10x10)
// - A filled rectangle and an outlined ellipse
// This gives a realistic (non-blank) render load for pdfrx frame-timing tests.
// ignore_for_file: avoid_print
import 'dart:io';
import 'dart:math';
import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter_test/flutter_test.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart';
void main() {
// Read PAGE_COUNT from --dart-define (default 300).
const int pageCount = int.fromEnvironment('PAGE_COUNT', defaultValue: 300);
test('generate test/assets/large_300p.pdf ($pageCount pages)', () {
final outputPath = _resolveOutputPath();
final outFile = File(outputPath);
outFile.parent.createSync(recursive: true);
final pdf = PdfDocument();
// Reusable fonts and brushes (created once, shared across pages).
final titleFont = PdfStandardFont(PdfFontFamily.helvetica, 18,
style: PdfFontStyle.bold);
final bodyFont = PdfStandardFont(PdfFontFamily.helvetica, 10);
final smallFont = PdfStandardFont(PdfFontFamily.helvetica, 8);
final blackBrush = PdfSolidBrush(PdfColor(0, 0, 0));
final darkBlueBrush = PdfSolidBrush(PdfColor(10, 30, 80));
final lightGrayBrush = PdfSolidBrush(PdfColor(220, 220, 220));
final accentBrush = PdfSolidBrush(PdfColor(60, 100, 200));
final gridPen = PdfPen(PdfColor(180, 180, 180), width: 0.3);
final borderPen = PdfPen(PdfColor(0, 0, 0), width: 1.0);
final accentPen = PdfPen(PdfColor(60, 100, 200), width: 1.5);
final rng = Random(42); // deterministic
for (int i = 1; i <= pageCount; i++) {
final page = pdf.pages.add();
final g = page.graphics;
final w = page.getClientSize().width;
final h = page.getClientSize().height;
// ── Title ──────────────────────────────────────────────────────────────
g.drawString(
'BadNote Benchmark — Page $i of $pageCount',
titleFont,
brush: darkBlueBrush,
bounds: Rect.fromLTWH(36, 30, w - 72, 28),
);
// Horizontal rule under title
g.drawLine(
PdfPen(PdfColor(60, 100, 200), width: 1.0),
Offset(36, 62),
Offset(w - 36, 62),
);
// ── Body text (two paragraphs) ────────────────────────────────────────
final paragraph1 =
'This page is part of a synthetic $pageCount-page benchmark PDF '
'generated by BadNote\'s tool/gen_bench_pdf.dart. Each page carries '
'non-trivial content (text, vector shapes, a line grid) to simulate '
'realistic rendering load for pdfrx frame-timing measurements. '
'Page index: $i. Seed value: ${rng.nextInt(99999)}.';
final paragraph2 =
'Performance target (MUST #4, §10/M1): pdfrx fling-scroll over '
'$pageCount pages in profile mode must stay at ≤ 16.6 ms median '
'frame time (build + raster) and ≤ 22 ms at p95, measured over '
'N ≥ 120 frames per §7.1 of the BadNote Phase 1 plan. If this gate '
'fails the backend choice is invalidated. Fill: ${_lorem(rng, 60)}.';
g.drawString(
paragraph1,
bodyFont,
brush: blackBrush,
bounds: Rect.fromLTWH(36, 72, w - 72, 80),
format: PdfStringFormat(lineSpacing: 4),
);
g.drawString(
paragraph2,
bodyFont,
brush: blackBrush,
bounds: Rect.fromLTWH(36, 158, w - 72, 80),
format: PdfStringFormat(lineSpacing: 4),
);
// ── 10×10 ruled grid ──────────────────────────────────────────────────
const gridLeft = 36.0;
const gridTop = 260.0;
final gridWidth = w - 72;
const gridHeight = 220.0;
const cols = 10;
const rows = 10;
final cellW = gridWidth / cols;
const cellH = gridHeight / rows;
for (int col = 0; col <= cols; col++) {
final x = gridLeft + col * cellW;
g.drawLine(
gridPen, Offset(x, gridTop), Offset(x, gridTop + gridHeight));
}
for (int row = 0; row <= rows; row++) {
const y = gridTop;
g.drawLine(gridPen, Offset(gridLeft, y + row * cellH),
Offset(gridLeft + gridWidth, y + row * cellH));
}
for (int row = 0; row < rows; row++) {
for (int col = 0; col < cols; col++) {
if ((row + col) % 3 == 0) {
g.drawRectangle(
brush: lightGrayBrush,
bounds: Rect.fromLTWH(
gridLeft + col * cellW + 0.5,
gridTop + row * cellH + 0.5,
cellW - 1,
cellH - 1,
),
);
}
}
}
g.drawRectangle(
pen: borderPen,
bounds: Rect.fromLTWH(gridLeft, gridTop, gridWidth, gridHeight),
);
for (int row = 0; row < rows; row++) {
g.drawString(
'R${row + 1}',
smallFont,
brush: blackBrush,
bounds: Rect.fromLTWH(
gridLeft + 2,
gridTop + row * cellH + 2,
cellW - 4,
cellH - 4,
),
);
}
// ── Accent shapes ──────────────────────────────────────────────────────
const shapeTop = gridTop + gridHeight + 18;
final rectW = 60.0 + (i % 8) * 10.0;
g.drawRectangle(
pen: accentPen,
brush: accentBrush,
bounds: Rect.fromLTWH(36, shapeTop, rectW, 24),
);
g.drawString(
'Page $i',
smallFont,
brush: PdfSolidBrush(PdfColor(255, 255, 255)),
bounds: Rect.fromLTWH(40, shapeTop + 6, rectW - 8, 14),
);
g.drawEllipse(
Rect.fromLTWH(36 + rectW + 16, shapeTop, 80, 24),
pen: accentPen,
);
// ── Footer ────────────────────────────────────────────────────────────
g.drawString(
'BadNote bench PDF • page $i/$pageCount • tool/gen_bench_pdf.dart',
smallFont,
brush: PdfSolidBrush(PdfColor(140, 140, 140)),
bounds: Rect.fromLTWH(36, h - 28, w - 72, 18),
format: PdfStringFormat(alignment: PdfTextAlignment.center),
);
}
final bytes = pdf.saveSync();
pdf.dispose();
outFile.writeAsBytesSync(bytes);
final sizeKb = (outFile.lengthSync() / 1024).toStringAsFixed(1);
print('Generated: $outputPath');
print('Pages: $pageCount');
print('Size: ${sizeKb} KB (${outFile.lengthSync()} bytes)');
expect(outFile.existsSync(), isTrue);
expect(outFile.lengthSync(), greaterThan(1024),
reason: 'PDF must be at least 1 KB');
}, timeout: const Timeout(Duration(minutes: 5)));
}
/// Resolves test/assets/large_300p.pdf relative to this script's location.
/// tool/gen_bench_pdf.dart → project root → test/assets/large_300p.pdf
String _resolveOutputPath() {
// When run via `flutter test`, the CWD is the project root.
return 'test/assets/large_300p.pdf';
}
/// Generates a deterministic Lorem-Ipsum-style filler of roughly [words] words.
String _lorem(Random rng, int words) {
const vocab = [
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'sed', 'eiusmod', 'tempor', 'incididunt',
'labore', 'dolore', 'magna', 'aliqua', 'enim', 'minim', 'veniam',
'quis', 'nostrud', 'exercitation', 'ullamco', 'laboris', 'nisi',
'aliquip', 'commodo', 'consequat', 'duis', 'aute', 'irure',
'reprehenderit', 'voluptate', 'velit', 'esse', 'cillum', 'fugiat',
'nulla', 'pariatur', 'excepteur', 'sint', 'occaecat', 'cupidatat',
'proident', 'culpa', 'officia', 'deserunt', 'mollit', 'anim',
];
return List.generate(words, (_) => vocab[rng.nextInt(vocab.length)])
.join(' ');
}

220
tool/gen_dense_strokes.dart Normal file
View File

@@ -0,0 +1,220 @@
// tool/gen_dense_strokes.dart
//
// Generates synthetic ink-stroke datasets as JSON matching InkStroke.toJson()
// (from lib/models/ink_stroke.dart + lib/models/ink_point.dart) exactly.
//
// Output: test/assets/dense_strokes.json
// Format:
// {
// "2000": [ ...2000 InkStroke objects... ],
// "5000": [ ...5000 InkStroke objects... ]
// }
//
// Each stroke:
// - 820 InkPoint objects
// - x/y ∈ [0,1] (normalized page space, matching InkStroke coordinate model)
// - pressure ∈ [0.2, 1.0]
// - tilt ∈ [0.0, 30.0] degrees
// - pointerDeviceKind: "stylus" (surface pen benchmark)
// - tool: "pen"
// - color: varied from a palette of realistic ink colors
// - strokeWidth: 1.04.0
//
// Usage:
// dart run tool/gen_dense_strokes.dart # 2000 + 5000 (defaults)
// dart run tool/gen_dense_strokes.dart 500 1000 # custom counts
//
// The counts are also the JSON keys (converted to strings).
import 'dart:convert';
import 'dart:io';
import 'dart:math';
void main(List<String> args) {
final counts = args.isNotEmpty
? args.map(int.parse).toList()
: [2000, 5000];
final outputPath = _resolveOutputPath();
File(outputPath).parent.createSync(recursive: true);
final rng = Random(12345); // deterministic seed for reproducibility
final Map<String, dynamic> result = {};
for (final count in counts) {
final strokes = List.generate(count, (i) => _generateStroke(rng, i));
result['$count'] = strokes;
print('Generated $count strokes');
}
final jsonStr = const JsonEncoder.withIndent(null).convert(result);
File(outputPath).writeAsStringSync(jsonStr);
final sizeKb = (File(outputPath).lengthSync() / 1024).toStringAsFixed(1);
print('Output: $outputPath');
print('Size: ${sizeKb} KB');
for (final count in counts) {
print(' "$count": ${(result[count.toString()] as List).length} strokes');
}
// ── Inline round-trip sanity check ─────────────────────────────────────
// Verify that the first stroke in the first dataset round-trips through the
// InkStroke JSON shape without data loss (field names, enum values, types).
_verifyRoundTrip(result[counts.first.toString()]);
}
/// Generates one InkStroke as a plain Map matching InkStroke.toJson().
///
/// Field names and enum string values are taken directly from the generated
/// code in:
/// lib/models/ink_stroke.g.dart (_$$InkStrokeImplToJson)
/// lib/models/ink_point.g.dart (_$$InkPointImplToJson)
///
/// InkStroke fields:
/// id, points, tool, color, strokeWidth, createdAt, filled,
/// textContent, fontSize
///
/// InkPoint fields:
/// x, y, pressure, tilt, timestamp, pointerDeviceKind
Map<String, dynamic> _generateStroke(Random rng, int index) {
// Pick a random color from a set of realistic ink tones.
// Stored as ARGB int (0xFF......) matching @Default(0xFF000000).
final color = _pickColor(rng);
final strokeWidth = 1.0 + rng.nextDouble() * 3.0; // [1.0, 4.0]
final pointCount = 8 + rng.nextInt(13); // [8, 20]
// Start position — random page location
double x = 0.05 + rng.nextDouble() * 0.90; // [0.05, 0.95]
double y = 0.05 + rng.nextDouble() * 0.90;
// Simulate a realistic hand-drawn stroke: incremental movement with
// small steps (realistic velocity on a ~A4 page at ~1000 DPI effective).
final points = <Map<String, dynamic>>[];
int timestamp = DateTime.now().millisecondsSinceEpoch - (5000 - index * 2);
for (int p = 0; p < pointCount; p++) {
// Step in a semi-consistent direction with jitter
final angle = rng.nextDouble() * 2 * pi;
final step = 0.005 + rng.nextDouble() * 0.015; // [0.005, 0.02] page-units
x = (x + cos(angle) * step).clamp(0.0, 1.0);
y = (y + sin(angle) * step).clamp(0.0, 1.0);
// Pressure ramps up then down (pen-press profile)
final t = p / (pointCount - 1);
final basePressure = sin(t * pi); // 0→1→0 over the stroke
final pressure = (0.2 + basePressure * 0.8 + (rng.nextDouble() - 0.5) * 0.1)
.clamp(0.2, 1.0);
final tilt = rng.nextDouble() * 30.0; // [0, 30] degrees
timestamp += 8 + rng.nextInt(8); // ~816 ms between points (120 Hz stylus)
points.add({
'x': _round6(x),
'y': _round6(y),
'pressure': _round6(pressure),
'tilt': _round6(tilt),
'timestamp': timestamp,
// enum string from _$InputDeviceKindEnumMap in ink_point.g.dart
'pointerDeviceKind': 'stylus',
});
}
// createdAt as ISO-8601 string (DateTime.toIso8601String() format)
final createdAt = DateTime.fromMillisecondsSinceEpoch(timestamp - pointCount * 12)
.toIso8601String();
return {
'id': 'bench_${index.toString().padLeft(6, '0')}',
'points': points,
// enum string from _$PenToolEnumMap in ink_stroke.g.dart
'tool': 'pen',
'color': color,
'strokeWidth': _round6(strokeWidth),
'createdAt': createdAt,
'filled': false,
'textContent': null,
'fontSize': 14.0,
};
}
/// Returns one of several realistic ink colors as an ARGB int.
/// These match the range of values that @Default(0xFF000000) int color stores.
int _pickColor(Random rng) {
// Palette: black, dark-blue, dark-red, dark-green, dark-purple, charcoal
const palette = [
0xFF000000, // black
0xFF0A1E50, // dark navy
0xFF800020, // dark red
0xFF1A4D1A, // dark green
0xFF3D0066, // dark purple
0xFF1C1C1C, // charcoal
0xFF002B5C, // midnight blue
0xFF4B0000, // deep crimson
];
return palette[rng.nextInt(palette.length)];
}
/// Rounds a double to 6 decimal places to keep JSON compact and exact.
double _round6(double v) => double.parse(v.toStringAsFixed(6));
/// Resolves test/assets/dense_strokes.json relative to this script.
String _resolveOutputPath() {
final scriptUri = Platform.script;
final toolDir = File.fromUri(scriptUri).parent;
final projectRoot = toolDir.parent;
return '${projectRoot.path}/test/assets/dense_strokes.json';
}
/// Minimal round-trip verification that confirms the JSON shape produced
/// here matches InkStroke.fromJson() expectations.
///
/// We cannot call actual Dart model classes (they import Flutter packages),
/// so we do a structural check: re-parse the JSON and assert that every
/// required field survives the round-trip with the correct type.
void _verifyRoundTrip(dynamic dataset) {
final strokes = dataset as List<dynamic>;
assert(strokes.isNotEmpty, 'Dataset must not be empty');
final raw = strokes.first as Map<String, dynamic>;
// Re-encode → decode to simulate fromJson parsing.
final encoded = jsonEncode(raw);
final decoded = jsonDecode(encoded) as Map<String, dynamic>;
// Assert required InkStroke fields exist with correct types.
void check(String field, Type type) {
final val = decoded[field];
assert(
val == null || val.runtimeType.toString().contains(type.toString()) || val is num || val is String || val is bool || val is List,
'Field "$field" missing or wrong type: ${val.runtimeType}',
);
}
assert(decoded['id'] is String, 'id must be String');
assert(decoded['points'] is List, 'points must be List');
assert(decoded['tool'] == 'pen', 'tool enum must be "pen"');
assert(decoded['color'] is int || decoded['color'] is num, 'color must be int/num');
assert(decoded['strokeWidth'] is double || decoded['strokeWidth'] is num,
'strokeWidth must be num');
assert(decoded['createdAt'] is String, 'createdAt must be String (ISO-8601)');
assert(decoded['filled'] is bool, 'filled must be bool');
assert(decoded['fontSize'] is double || decoded['fontSize'] is num,
'fontSize must be num');
final points = decoded['points'] as List<dynamic>;
assert(points.isNotEmpty, 'stroke must have at least one point');
final p0 = points.first as Map<String, dynamic>;
assert(p0['x'] is num, 'InkPoint.x must be num');
assert(p0['y'] is num, 'InkPoint.y must be num');
assert(p0['pressure'] is num, 'InkPoint.pressure must be num');
assert(p0['tilt'] is num, 'InkPoint.tilt must be num');
assert(p0['timestamp'] is int || p0['timestamp'] is num,
'InkPoint.timestamp must be int/num');
assert(p0['pointerDeviceKind'] == 'stylus',
'pointerDeviceKind must be "stylus"');
print('Round-trip check: PASS (all required fields present with correct types)');
}

39
tool/test.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# tool/test.sh — flutter test wrapper with sqlite3 workaround.
#
# WHY THIS EXISTS:
# BadNote vendors sqlite3 native binaries under vendor/sqlite3/ (selected via
# the pubspec.yaml `hooks.user_defines.sqlite3.source: test-sqlite3` block).
# On Linux the vendored file is `vendor/sqlite3/libsqlite3.x64.linux.so`.
# Without pointing the dynamic linker at it, `flutter test` either falls back
# to a system sqlite3 (wrong version / missing) or tries to download one at
# build time (blocked behind the GFW on this machine).
#
# Setting LD_LIBRARY_PATH to the vendor dir tells the linker to prefer the
# vendored shared library. The Flutter toolchain here (3.41.4 / Dart 3.10.8)
# does NOT forward proxy env vars to build hooks, so LD_LIBRARY_PATH is the
# reliable workaround for local Linux development.
#
# On Windows CI the vendored sqlite3.x64.windows.dll is picked up
# automatically by the native-asset build — no wrapper needed there.
#
# USAGE:
# tool/test.sh # run all tests
# tool/test.sh test/foo_test.dart # run a specific test file
# tool/test.sh --coverage # pass any flutter test flags
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
VENDOR_SQLITE="${PROJECT_ROOT}/vendor/sqlite3"
if [ ! -d "${VENDOR_SQLITE}" ]; then
echo "WARNING: vendor/sqlite3/ not found at ${VENDOR_SQLITE}" >&2
echo " Proceeding without LD_LIBRARY_PATH override." >&2
else
export LD_LIBRARY_PATH="${VENDOR_SQLITE}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
exec flutter test "$@"