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 createState() => _OfficeDocumentScreenState(); } class _OfficeDocumentScreenState extends State { 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 _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 _loadSidecar() async { final f = File(_sidecarPath); if (!await f.exists()) return; try { final json = jsonDecode(await f.readAsString()) as Map; final pages = json['pages'] as Map? ?? {}; final key = '$_pageIndex'; final list = pages[key] as List? ?? []; _strokes ..clear() ..addAll(list.map((e) => _InkStroke.fromJson(e as Map))); } catch (_) {} } Future _saveSidecar() async { Map root = {'version': 1, 'pages': {}}; final f = File(_sidecarPath); if (await f.exists()) { try { root = jsonDecode(await f.readAsString()) as Map; } catch (_) {} } final pages = (root['pages'] as Map?) ?? {}; pages['$_pageIndex'] = _strokes.map((s) => s.toJson()).toList(); root['pages'] = pages; await f.writeAsString(const JsonEncoder.withIndent(' ').convert(root)); } Future _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 points; final List pressures; Map toJson() => { 'points': [ for (final p in points) {'x': p.dx, 'y': p.dy}, ], 'pressures': pressures, }; factory _InkStroke.fromJson(Map json) { final pts = (json['points'] as List) .map((e) => Offset( (e['x'] as num).toDouble(), (e['y'] as num).toDouble(), )) .toList(); final pr = (json['pressures'] as List?) ?.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; }