diff --git a/.gitignore b/.gitignore index 278e0e1..055cf9d 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ server/.omc/ server/data/ server/*.db server/.env + +# M1 spike generated bench assets (regenerate via tool/gen_*.dart) +/test/assets/large_300p.pdf +/test/assets/dense_strokes.json diff --git a/integration_test/coordinate_assertion_test.dart b/integration_test/coordinate_assertion_test.dart new file mode 100644 index 0000000..b939fb0 --- /dev/null +++ b/integration_test/coordinate_assertion_test.dart @@ -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 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( + 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 _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 _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; +} diff --git a/integration_test/perf_scroll_bench.dart b/integration_test/perf_scroll_bench.dart new file mode 100644 index 0000000..7e8eb69 --- /dev/null +++ b/integration_test/perf_scroll_bench.dart @@ -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 values) + : median = _pct(values, 50), + p95 = _pct(values, 95), + worst = values.isEmpty ? 0 : (List.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 v, int p) { + if (v.isEmpty) return 0; + final s = List.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(); + + 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 = []; + final raster = []; + final total = []; + var seen = 0; + void onTimings(List 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)}'; diff --git a/lib/editor/pdf/pen_capture_region.dart b/lib/editor/pdf/pen_capture_region.dart new file mode 100644 index 0000000..f0fd5ee --- /dev/null +++ b/lib/editor/pdf/pen_capture_region.dart @@ -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 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); + } +} diff --git a/lib/editor/pdf/spike_app.dart b/lib/editor/pdf/spike_app.dart new file mode 100644 index 0000000..b9dd21f --- /dev/null +++ b/lib/editor/pdf/spike_app.dart @@ -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 createState() => _SpikeHomeState(); +} + +class _SpikeHomeState extends State { + final GlobalKey _paneKey = + GlobalKey(); + 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 createState() => _FrameTimingHudState(); +} + +class _FrameTimingHudState extends State { + static const int _window = 120; + final List _build = []; + final List _raster = []; + 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 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 values, int p) { + if (values.isEmpty) return 0; + final sorted = List.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'), + ])), + ], + ), + ), + ), + ); + } +} diff --git a/lib/editor/pdf/spike_editor_pane.dart b/lib/editor/pdf/spike_editor_pane.dart new file mode 100644 index 0000000..9da254f --- /dev/null +++ b/lib/editor/pdf/spike_editor_pane.dart @@ -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 createState() => SpikeEditorPaneState(); +} + +class SpikeEditorPaneState extends State { + late final PdfViewerController _controller = + widget.controller ?? PdfViewerController(); + + /// Live pen strokes captured via PenCaptureRegion (viewer-level overlay). + final List> _penStrokes = >[]; + 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>? _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 setInkLoad(bool enabled) async { + if (enabled && _syntheticStrokes == null) { + await _loadSyntheticStrokes(); + } + if (mounted) setState(() => _inkLoadEnabled = enabled); + } + + Future _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; + final strokesJson = + (decoded[widget.strokeCountKey] as List? ?? const []); + final result = >[]; + for (final s in strokesJson) { + final points = (s as Map)['points'] as List; + final poly = []; + for (final p in points) { + final pt = p as Map; + 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> _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 = >[]; + 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>? 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> 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; +} diff --git a/lib/editor/pdf/spike_launcher.dart b/lib/editor/pdf/spike_launcher.dart new file mode 100644 index 0000000..fb4ca03 --- /dev/null +++ b/lib/editor/pdf/spike_launcher.dart @@ -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 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), + ), + ), + ); +} diff --git a/lib/editor/pdf/spike_main.dart b/lib/editor/pdf/spike_main.dart new file mode 100644 index 0000000..7feb0cd --- /dev/null +++ b/lib/editor/pdf/spike_main.dart @@ -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 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 = [ + _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; +} diff --git a/lib/main.dart b/lib/main.dart index 736728a..99ba197 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:pdfrx/pdfrx.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'editor/pdf/pen_capture_region.dart'; import 'providers/settings_provider.dart'; import 'screens/home_screen.dart'; import 'services/database_service.dart'; Future 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 await DatabaseService.getInstance(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 62f1645..7987ae8 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -5,6 +5,7 @@ import '../models/note.dart'; import '../providers/document_provider.dart'; import '../providers/note_provider.dart'; import '../providers/ocr_provider.dart'; +import '../editor/pdf/spike_launcher.dart'; import '../services/pdf_service.dart'; import '../services/pptx_service.dart'; import 'note_editor_screen.dart'; @@ -67,6 +68,12 @@ class HomeScreen extends ConsumerWidget { ).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( diff --git a/pubspec.lock b/pubspec.lock index aa38ed3..1df2208 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -318,6 +326,11 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -384,6 +397,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -440,6 +458,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -504,6 +530,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: transitive description: @@ -712,6 +743,38 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -752,6 +815,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -808,6 +887,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shared_preferences: dependency: "direct main" description: @@ -1005,6 +1092,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -1229,6 +1324,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: @@ -1271,4 +1374,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.8 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 19443d6..e9e4c82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: # Embedded ONNX runtime (local OCR recognition backend) flutter_onnxruntime: ^1.8.0 + pdfrx: ^2.4.4 # 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 @@ -61,6 +62,9 @@ dependency_overrides: dev_dependencies: flutter_test: sdk: flutter + # M1 spike perf/coordinate gates (integration_test harness). + integration_test: + sdk: flutter flutter_lints: ^6.0.0 # Code Generation diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000..b12a8a6 --- /dev/null +++ b/test_driver/integration_test.dart @@ -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 main() => integrationDriver(); diff --git a/tool/gen_bench_pdf.dart b/tool/gen_bench_pdf.dart new file mode 100644 index 0000000..43affe6 --- /dev/null +++ b/tool/gen_bench_pdf.dart @@ -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(' '); +} diff --git a/tool/gen_dense_strokes.dart b/tool/gen_dense_strokes.dart new file mode 100644 index 0000000..dde7d3d --- /dev/null +++ b/tool/gen_dense_strokes.dart @@ -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: +// - 8–20 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.0–4.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 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 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 _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 = >[]; + 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); // ~8–16 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; + assert(strokes.isNotEmpty, 'Dataset must not be empty'); + + final raw = strokes.first as Map; + + // Re-encode → decode to simulate fromJson parsing. + final encoded = jsonEncode(raw); + final decoded = jsonDecode(encoded) as Map; + + // 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; + assert(points.isNotEmpty, 'stroke must have at least one point'); + final p0 = points.first as Map; + + 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)'); +} diff --git a/tool/test.sh b/tool/test.sh new file mode 100755 index 0000000..7fb5e5d --- /dev/null +++ b/tool/test.sh @@ -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 "$@"