// 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'), ])), ], ), ), ), ); } }