feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s

Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

View File

@@ -8,6 +8,7 @@
import 'package:flutter/foundation.dart';
import '../../diagnostics/frame_sampler.dart';
import '../input/diagnostic_logger.dart';
class InputDiagnostics extends ChangeNotifier {
@@ -62,6 +63,12 @@ class InputDiagnostics extends ChangeNotifier {
'${scaleDrop ? " SDROP" : ""}${focalDrop ? " FDROP" : ""}';
_trace.add(line);
if (_trace.length > 24) _trace.removeAt(0);
FrameSampler.instance.recordZoom(
rawScale: rawScale,
scaleDrop: scaleDrop,
focalDrop: focalDrop,
focalJumpPx: focalJumpPx,
);
DiagnosticLogger.instance.log('ZOOM $line');
notifyListeners();
}

View File

@@ -0,0 +1,328 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../diagnostics/badnote_log.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../../services/office/docx_parser.dart';
import '../../services/office/office_document.dart';
import '../../services/office/pptx_parser.dart';
import '../../theme/app_theme.dart';
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
class OfficeDocumentScreen extends StatefulWidget {
const OfficeDocumentScreen({
super.key,
required this.filePath,
});
final String filePath;
@override
State<OfficeDocumentScreen> createState() => _OfficeDocumentScreenState();
}
class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
bool _loading = true;
String? _error;
ParsedPptx? _pptx;
ParsedDocx? _docx;
int _pageIndex = 0;
final List<_InkStroke> _strokes = [];
_InkStroke? _live;
final TransformationController _transform = TransformationController();
String get _sidecarPath => '${widget.filePath}.badnote.json';
@override
void initState() {
super.initState();
_open();
}
@override
void dispose() {
_transform.dispose();
super.dispose();
}
Future<void> _open() async {
final ext = p.extension(widget.filePath).toLowerCase();
try {
if (ext == '.pptx' || ext == '.ppt') {
_pptx = await PptxParser().parse(widget.filePath);
} else if (ext == '.docx') {
_docx = await DocxParser().parse(widget.filePath);
} else {
throw StateError('Unsupported: $ext');
}
await _loadSidecar();
BadNoteLog.instance.info(LogSubsystem.office, 'office_open', fields: {
'path': widget.filePath,
'pages': pageCount,
});
} catch (e) {
_error = '$e';
BadNoteLog.instance.error(LogSubsystem.office, 'office_open_failed', fields: {
'error': '$e',
});
}
if (mounted) setState(() => _loading = false);
}
int get pageCount {
if (_pptx != null) return _pptx!.slides.length;
if (_docx != null) return (_docx!.blocks.length / 12).ceil().clamp(1, 9999);
return 0;
}
Future<void> _loadSidecar() async {
final f = File(_sidecarPath);
if (!await f.exists()) return;
try {
final json = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
final pages = json['pages'] as Map<String, dynamic>? ?? {};
final key = '$_pageIndex';
final list = pages[key] as List<dynamic>? ?? [];
_strokes
..clear()
..addAll(list.map((e) => _InkStroke.fromJson(e as Map<String, dynamic>)));
} catch (_) {}
}
Future<void> _saveSidecar() async {
Map<String, dynamic> root = {'version': 1, 'pages': <String, dynamic>{}};
final f = File(_sidecarPath);
if (await f.exists()) {
try {
root = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
} catch (_) {}
}
final pages = (root['pages'] as Map<String, dynamic>?) ?? {};
pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList();
root['pages'] = pages;
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root));
}
Future<void> _goPage(int i) async {
await _saveSidecar();
setState(() {
_pageIndex = i.clamp(0, pageCount - 1);
_strokes.clear();
_live = null;
});
await _loadSidecar();
if (mounted) setState(() {});
}
void _onPointerDown(PointerDownEvent e) {
if (e.kind != ui.PointerDeviceKind.stylus &&
e.kind != ui.PointerDeviceKind.invertedStylus &&
e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final local = _toScene(e.localPosition);
_live = _InkStroke(points: [local], pressures: [e.pressure]);
PenEventRing.instance.recordPointer(
kind: 'down',
pointerId: e.pointer,
deviceKind: e.kind.name,
pressure: e.pressure,
decision: 'draw',
);
setState(() {});
}
void _onPointerMove(PointerMoveEvent e) {
final live = _live;
if (live == null) return;
live.points.add(_toScene(e.localPosition));
live.pressures.add(e.pressure);
setState(() {});
}
void _onPointerUp(PointerUpEvent e) {
final live = _live;
if (live == null) return;
setState(() {
_strokes.add(live);
_live = null;
});
unawaited(_saveSidecar());
}
Offset _toScene(Offset local) {
final inv = Matrix4.inverted(_transform.value);
return MatrixUtils.transformPoint(inv, local);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_error != null) {
return Scaffold(
appBar: AppBar(title: Text(p.basename(widget.filePath))),
body: Center(child: Text(_error!)),
);
}
return Scaffold(
appBar: AppBar(
title: Text(p.basename(widget.filePath)),
actions: [
IconButton(
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
icon: const Icon(Icons.chevron_left),
),
Center(child: Text('${_pageIndex + 1} / $pageCount')),
IconButton(
onPressed:
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
icon: const Icon(Icons.chevron_right),
),
],
),
body: InteractiveViewer(
transformationController: _transform,
minScale: 0.5,
maxScale: 4,
child: Listener(
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
child: CustomPaint(
painter: _OfficePagePainter(
pptx: _pptx,
docx: _docx,
pageIndex: _pageIndex,
strokes: _strokes,
live: _live,
),
size: _pageSize,
),
),
),
);
}
Size get _pageSize {
if (_pptx != null && _pptx!.slides.isNotEmpty) {
final s = _pptx!.slides[_pageIndex.clamp(0, _pptx!.slides.length - 1)];
return Size(s.width, s.height);
}
return const Size(800, 1100);
}
}
class _InkStroke {
_InkStroke({required this.points, required this.pressures});
final List<Offset> points;
final List<double> pressures;
Map<String, dynamic> toJson() => {
'points': [
for (final p in points) {'x': p.dx, 'y': p.dy},
],
'pressures': pressures,
};
factory _InkStroke.fromJson(Map<String, dynamic> json) {
final pts = (json['points'] as List<dynamic>)
.map((e) => Offset(
(e['x'] as num).toDouble(),
(e['y'] as num).toDouble(),
))
.toList();
final pr = (json['pressures'] as List<dynamic>?)
?.map((e) => (e as num).toDouble())
.toList() ??
List.filled(pts.length, 0.5);
return _InkStroke(points: pts, pressures: pr);
}
}
class _OfficePagePainter extends CustomPainter {
_OfficePagePainter({
required this.pptx,
required this.docx,
required this.pageIndex,
required this.strokes,
required this.live,
});
final ParsedPptx? pptx;
final ParsedDocx? docx;
final int pageIndex;
final List<_InkStroke> strokes;
final _InkStroke? live;
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = AppTokens.paper;
canvas.drawRect(Offset.zero & size, bg);
if (pptx != null && pptx!.slides.isNotEmpty) {
final slide = pptx!.slides[pageIndex.clamp(0, pptx!.slides.length - 1)];
final border = Paint()
..color = AppTokens.rule
..style = PaintingStyle.stroke;
canvas.drawRect(Offset.zero & Size(slide.width, slide.height), border);
for (final run in slide.runs) {
final tp = TextPainter(
text: TextSpan(
text: run.text,
style: TextStyle(
color: AppTokens.ink,
fontSize: run.fontSize,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: run.width > 0 ? run.width : slide.width - 96);
tp.paint(canvas, Offset(run.x, run.y));
}
} else if (docx != null) {
final start = pageIndex * 12;
final blocks = docx!.blocks.skip(start).take(12).toList();
var y = 48.0;
for (final b in blocks) {
final style = TextStyle(
color: AppTokens.ink,
fontSize: b.type == DocBlockType.heading ? 22 - b.level * 2.0 : 15,
fontWeight:
b.type == DocBlockType.heading ? FontWeight.w700 : FontWeight.w400,
);
final tp = TextPainter(
text: TextSpan(text: b.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: size.width - 96);
tp.paint(canvas, Offset(48, y));
y += tp.height + 12;
}
}
final ink = Paint()
..color = AppTokens.copper
..style = PaintingStyle.stroke
..strokeWidth = 2.2
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
for (final s in [...strokes, if (live != null) live!]) {
if (s.points.length < 2) continue;
final path = Path()..moveTo(s.points.first.dx, s.points.first.dy);
for (var i = 1; i < s.points.length; i++) {
path.lineTo(s.points[i].dx, s.points[i].dy);
}
canvas.drawPath(path, ink);
}
}
@override
bool shouldRepaint(covariant _OfficePagePainter oldDelegate) => true;
}

View File

@@ -25,11 +25,13 @@ import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_predictor.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../../diagnostics/pen_event_ring.dart';
import '../engine/shape_geometry.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
@@ -210,6 +212,9 @@ class _PenCanvasState extends State<PenCanvas> {
/// In-progress stroke points (normalized).
final List<PenPoint> _livePoints = [];
final StrokePredictor _predictor = StrokePredictor();
/// Count of real (non-predicted) points in [_livePoints].
int _realPointCount = 0;
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke;
@@ -407,12 +412,21 @@ class _PenCanvasState extends State<PenCanvas> {
/// Decide whether the gesture currently forming should DRAW. Delegates to the
/// pure [arbiter.shouldDraw] (unit-tested truth table) so the live canvas and
/// the tests can never disagree on the rule.
bool _shouldDraw(PointerDeviceKind kind) => arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
bool _shouldDraw(PointerDeviceKind kind) {
final draw = arbiter.shouldDraw(
activePointerCount: _activePointers.length,
kind: kind,
fingerDrawingEnabled: _fingerDrawingEnabled,
hwPanActive: _hwPanActive,
);
PenEventRing.instance.recordArbiter(
activeCount: _activePointers.length,
deviceKind: kind.name,
draw: draw,
fingerDrawing: _fingerDrawingEnabled,
);
return draw;
}
// --- Coordinate mapping ---------------------------------------------------
@@ -437,6 +451,8 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
@@ -469,7 +485,10 @@ class _PenCanvasState extends State<PenCanvas> {
return;
}
if (p != null) _livePoints.add(p);
if (p != null) {
_livePoints.add(p);
_realPointCount = _livePoints.length;
}
_updateLiveStroke();
}
@@ -507,7 +526,21 @@ class _PenCanvasState extends State<PenCanvas> {
return;
}
// Drop previous predicted tip before appending the real sample.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
_livePoints.add(p);
_realPointCount = _livePoints.length;
final pred = _predictor.observe(Offset(p.x, p.y), p.pressure ?? 0.5);
if (pred != null) {
_livePoints.add(PenPoint(
pred.offset.dx.clamp(0.0, 1.0),
pred.offset.dy.clamp(0.0, 1.0),
pred.pressure,
tilt: p.tilt,
));
}
_updateLiveStroke();
}
@@ -533,6 +566,10 @@ class _PenCanvasState extends State<PenCanvas> {
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
// Never commit predicted tips — only real digitizer samples.
if (_livePoints.length > _realPointCount) {
_livePoints.removeRange(_realPointCount, _livePoints.length);
}
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
@@ -548,6 +585,8 @@ class _PenCanvasState extends State<PenCanvas> {
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_realPointCount = 0;
_predictor.reset();
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null);
}