feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s
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:
164
lib/diagnostics/badnote_log.dart
Normal file
164
lib/diagnostics/badnote_log.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
// Global structured logging bus for BadNote.
|
||||
//
|
||||
// Always-on (unlike the old PDF-only DiagnosticLogger opt-in). Writes NDJSON
|
||||
// lines to a rotating session file under the app documents directory so a
|
||||
// Surface user can export a diagnostic pack without attaching a debugger.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
enum LogLevel { trace, debug, info, warn, error }
|
||||
|
||||
/// Known subsystems — keep the set small so filters stay useful.
|
||||
abstract final class LogSubsystem {
|
||||
static const shell = 'shell';
|
||||
static const ink = 'ink';
|
||||
static const arbiter = 'arbiter';
|
||||
static const penNative = 'pen_native';
|
||||
static const pdf = 'pdf';
|
||||
static const office = 'office';
|
||||
static const board = 'board';
|
||||
static const sync = 'sync';
|
||||
static const diag = 'diag';
|
||||
static const frame = 'frame';
|
||||
}
|
||||
|
||||
class BadNoteLog {
|
||||
BadNoteLog._();
|
||||
static final BadNoteLog instance = BadNoteLog._();
|
||||
|
||||
final String sessionId = const Uuid().v4();
|
||||
final List<Map<String, Object?>> _ring = <Map<String, Object?>>[];
|
||||
static const int _ringCap = 4000;
|
||||
|
||||
File? _file;
|
||||
Directory? _dir;
|
||||
Timer? _flushTimer;
|
||||
final List<String> _pending = <String>[];
|
||||
bool _started = false;
|
||||
LogLevel minLevel = LogLevel.debug;
|
||||
|
||||
/// Absolute path of the current session log, once [start] succeeds.
|
||||
String? get path => _file?.path;
|
||||
|
||||
Directory? get directory => _dir;
|
||||
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
try {
|
||||
Directory base;
|
||||
try {
|
||||
base = await getApplicationDocumentsDirectory();
|
||||
} catch (_) {
|
||||
base = await getTemporaryDirectory();
|
||||
}
|
||||
_dir = Directory(
|
||||
'${base.path}${Platform.pathSeparator}badnote_diagnostics',
|
||||
);
|
||||
if (!await _dir!.exists()) {
|
||||
await _dir!.create(recursive: true);
|
||||
}
|
||||
final stamp = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll('.', '-');
|
||||
_file = File(
|
||||
'${_dir!.path}${Platform.pathSeparator}session_$stamp.ndjson',
|
||||
);
|
||||
await _file!.writeAsString(
|
||||
'${jsonEncode({
|
||||
'ts': DateTime.now().toIso8601String(),
|
||||
'level': 'info',
|
||||
'subsystem': LogSubsystem.diag,
|
||||
'msg': 'session_start',
|
||||
'sessionId': sessionId,
|
||||
'platform': Platform.operatingSystem,
|
||||
'osVersion': Platform.operatingSystemVersion,
|
||||
})}\n',
|
||||
flush: true,
|
||||
);
|
||||
_flushTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flush());
|
||||
info(LogSubsystem.diag, 'log file ready', fields: {'path': _file!.path});
|
||||
} catch (e) {
|
||||
// Logging must never crash the app.
|
||||
developer.log('BadNoteLog start failed: $e', name: 'badnote');
|
||||
}
|
||||
}
|
||||
|
||||
void trace(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||
_emit(LogLevel.trace, subsystem, msg, fields);
|
||||
|
||||
void debug(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||
_emit(LogLevel.debug, subsystem, msg, fields);
|
||||
|
||||
void info(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||
_emit(LogLevel.info, subsystem, msg, fields);
|
||||
|
||||
void warn(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||
_emit(LogLevel.warn, subsystem, msg, fields);
|
||||
|
||||
void error(String subsystem, String msg, {Map<String, Object?>? fields}) =>
|
||||
_emit(LogLevel.error, subsystem, msg, fields);
|
||||
|
||||
void _emit(
|
||||
LogLevel level,
|
||||
String subsystem,
|
||||
String msg,
|
||||
Map<String, Object?>? fields,
|
||||
) {
|
||||
if (level.index < minLevel.index) return;
|
||||
final entry = <String, Object?>{
|
||||
'ts': DateTime.now().toIso8601String(),
|
||||
'level': level.name,
|
||||
'subsystem': subsystem,
|
||||
'msg': msg,
|
||||
'sessionId': sessionId,
|
||||
if (fields != null) ...fields,
|
||||
};
|
||||
_ring.add(entry);
|
||||
if (_ring.length > _ringCap) {
|
||||
_ring.removeRange(0, _ring.length - _ringCap);
|
||||
}
|
||||
final line = jsonEncode(entry);
|
||||
developer.log(line, name: 'badnote.$subsystem');
|
||||
if (_file != null) {
|
||||
_pending.add(line);
|
||||
if (_pending.length >= 200) {
|
||||
unawaited(_flush());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _flush() async {
|
||||
final file = _file;
|
||||
if (file == null || _pending.isEmpty) return;
|
||||
final chunk = '${_pending.join('\n')}\n';
|
||||
_pending.clear();
|
||||
try {
|
||||
await file.writeAsString(chunk, mode: FileMode.append, flush: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Snapshot of the in-memory ring (newest last).
|
||||
List<Map<String, Object?>> snapshotRing() =>
|
||||
List<Map<String, Object?>>.unmodifiable(_ring);
|
||||
|
||||
Future<void> flush() => _flush();
|
||||
|
||||
Future<void> stop() async {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
await _flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge for legacy call sites that still use plain strings.
|
||||
void logLegacyInputLine(String line) {
|
||||
BadNoteLog.instance.debug(LogSubsystem.penNative, line);
|
||||
}
|
||||
174
lib/diagnostics/diagnostic_chrome.dart
Normal file
174
lib/diagnostics/diagnostic_chrome.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'badnote_log.dart';
|
||||
import 'diagnostic_export.dart';
|
||||
import '../editor/canvas/input_diagnostics.dart';
|
||||
import '../editor/input/diagnostic_logger.dart';
|
||||
import '../editor/input/pen_input_service.dart';
|
||||
|
||||
/// Shared diagnostics chrome: overlay readout + export action.
|
||||
/// Mount on any document surface (note / PDF / PPT / board).
|
||||
class DiagnosticChrome extends StatefulWidget {
|
||||
const DiagnosticChrome({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.initiallyVisible = false,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool initiallyVisible;
|
||||
|
||||
@override
|
||||
State<DiagnosticChrome> createState() => DiagnosticChromeState();
|
||||
}
|
||||
|
||||
class DiagnosticChromeState extends State<DiagnosticChrome> {
|
||||
late bool _visible = widget.initiallyVisible;
|
||||
bool _exporting = false;
|
||||
String? _lastExportPath;
|
||||
|
||||
bool get isVisible => _visible;
|
||||
|
||||
void toggle() {
|
||||
setState(() {
|
||||
_visible = !_visible;
|
||||
if (_visible) {
|
||||
DiagnosticLogger.instance.start();
|
||||
InputDiagnostics.instance.reset();
|
||||
} else {
|
||||
DiagnosticLogger.instance.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> exportPack() async {
|
||||
if (_exporting) return;
|
||||
setState(() => _exporting = true);
|
||||
try {
|
||||
final result = await DiagnosticExport.instance.exportPack();
|
||||
if (!mounted) return;
|
||||
setState(() => _lastExportPath = result.zipPath);
|
||||
await Clipboard.setData(ClipboardData(text: result.zipPath));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'诊断包已导出 (${result.bytes} bytes)\n路径已复制到剪贴板',
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
BadNoteLog.instance.error(LogSubsystem.diag, 'export_failed', fields: {
|
||||
'error': '$e',
|
||||
});
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('导出失败: $e')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _exporting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
widget.child,
|
||||
if (_visible)
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
height: 1.35,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListenableBuilder(
|
||||
listenable: InputDiagnostics.instance,
|
||||
builder: (context, _) {
|
||||
return Text(
|
||||
'${InputDiagnostics.instance.summary()}\n'
|
||||
'${PenInputService.instance.debugSummary}\n'
|
||||
'log: ${BadNoteLog.instance.path ?? "(starting…)"}\n'
|
||||
'session: ${BadNoteLog.instance.sessionId}'
|
||||
'${_lastExportPath != null ? "\nlast zip: $_lastExportPath" : ""}',
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => InputDiagnostics.instance.reset(),
|
||||
child: const Text('Reset',
|
||||
style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _exporting ? null : exportPack,
|
||||
child: Text(
|
||||
_exporting ? 'Exporting…' : 'Export pack',
|
||||
style: const TextStyle(color: Colors.lightGreenAccent),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: toggle,
|
||||
child: const Text('Hide',
|
||||
style: TextStyle(color: Colors.white54)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact icon button for app bars / toolbars.
|
||||
class DiagnosticToggleButton extends StatelessWidget {
|
||||
const DiagnosticToggleButton({
|
||||
super.key,
|
||||
required this.onToggle,
|
||||
required this.onExport,
|
||||
});
|
||||
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onExport;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: 'Diagnostics',
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
onSelected: (v) {
|
||||
if (v == 'toggle') onToggle();
|
||||
if (v == 'export') onExport();
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(value: 'toggle', child: Text('Toggle overlay')),
|
||||
PopupMenuItem(value: 'export', child: Text('Export diagnostic pack')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/diagnostics/diagnostic_export.dart
Normal file
138
lib/diagnostics/diagnostic_export.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
// Build a zip diagnostic pack the user can hand back for remote debugging.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../editor/canvas/input_diagnostics.dart';
|
||||
import '../editor/input/pen_input_service.dart';
|
||||
import 'badnote_log.dart';
|
||||
import 'frame_sampler.dart';
|
||||
import 'pen_event_ring.dart';
|
||||
|
||||
class DiagnosticExportResult {
|
||||
DiagnosticExportResult({required this.zipPath, required this.bytes});
|
||||
|
||||
final String zipPath;
|
||||
final int bytes;
|
||||
}
|
||||
|
||||
class DiagnosticExport {
|
||||
DiagnosticExport._();
|
||||
static final DiagnosticExport instance = DiagnosticExport._();
|
||||
|
||||
/// Flush logs and write a zip under Documents/badnote_diagnostics/.
|
||||
Future<DiagnosticExportResult> exportPack({
|
||||
Duration penWindow = const Duration(minutes: 5),
|
||||
}) async {
|
||||
final log = BadNoteLog.instance;
|
||||
await log.flush();
|
||||
|
||||
final meta = <String, Object?>{
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'sessionId': log.sessionId,
|
||||
'platform': Platform.operatingSystem,
|
||||
'osVersion': Platform.operatingSystemVersion,
|
||||
'localHostname': Platform.localHostname,
|
||||
'numberOfProcessors': Platform.numberOfProcessors,
|
||||
'flutter': {
|
||||
'foundationDebug': kDebugMode,
|
||||
'foundationProfile': kProfileMode,
|
||||
'foundationRelease': kReleaseMode,
|
||||
},
|
||||
'penNative': PenInputService.instance.debugSummary,
|
||||
'penActive': PenInputService.instance.isActive,
|
||||
'zoom': InputDiagnostics.instance.summary(),
|
||||
'frames': FrameSampler.instance.summary(),
|
||||
'instructions':
|
||||
'Reproduce the issue for ~3 minutes with diagnostics on, then share this zip.',
|
||||
};
|
||||
|
||||
final archive = Archive();
|
||||
void addText(String name, String body) {
|
||||
final bytes = utf8.encode(body);
|
||||
archive.addFile(ArchiveFile(name, bytes.length, bytes));
|
||||
}
|
||||
|
||||
addText('meta.json', const JsonEncoder.withIndent(' ').convert(meta));
|
||||
addText(
|
||||
'pen_events.json',
|
||||
const JsonEncoder.withIndent(' ').convert(
|
||||
PenEventRing.instance.toJsonList(window: penWindow),
|
||||
),
|
||||
);
|
||||
addText(
|
||||
'frame_samples.json',
|
||||
const JsonEncoder.withIndent(' ').convert(FrameSampler.instance.toJsonList()),
|
||||
);
|
||||
addText(
|
||||
'log_ring.json',
|
||||
const JsonEncoder.withIndent(' ').convert(log.snapshotRing()),
|
||||
);
|
||||
|
||||
// Include on-disk session NDJSON if present.
|
||||
final sessionPath = log.path;
|
||||
if (sessionPath != null) {
|
||||
try {
|
||||
final f = File(sessionPath);
|
||||
if (await f.exists()) {
|
||||
final bytes = await f.readAsBytes();
|
||||
archive.addFile(
|
||||
ArchiveFile('session.ndjson', bytes.length, bytes),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Legacy input log if it exists alongside.
|
||||
try {
|
||||
Directory dir;
|
||||
try {
|
||||
dir = await getApplicationDocumentsDirectory();
|
||||
} catch (_) {
|
||||
dir = await getTemporaryDirectory();
|
||||
}
|
||||
final legacy = File(
|
||||
'${dir.path}${Platform.pathSeparator}badnote_input_log.txt',
|
||||
);
|
||||
if (await legacy.exists()) {
|
||||
final bytes = await legacy.readAsBytes();
|
||||
archive.addFile(
|
||||
ArchiveFile('legacy_input_log.txt', bytes.length, bytes),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final encoded = ZipEncoder().encode(archive);
|
||||
if (encoded.isEmpty) {
|
||||
throw StateError('Failed to encode diagnostic zip');
|
||||
}
|
||||
|
||||
Directory outDir = log.directory ??
|
||||
Directory(
|
||||
'${(await getApplicationDocumentsDirectory()).path}'
|
||||
'${Platform.pathSeparator}badnote_diagnostics',
|
||||
);
|
||||
if (!await outDir.exists()) {
|
||||
await outDir.create(recursive: true);
|
||||
}
|
||||
final stamp = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceAll(':', '-')
|
||||
.replaceAll('.', '-');
|
||||
final zipPath =
|
||||
'${outDir.path}${Platform.pathSeparator}badnote_diag_$stamp.zip';
|
||||
await File(zipPath).writeAsBytes(encoded, flush: true);
|
||||
|
||||
BadNoteLog.instance.info(
|
||||
LogSubsystem.diag,
|
||||
'export_pack',
|
||||
fields: {'path': zipPath, 'bytes': encoded.length},
|
||||
);
|
||||
|
||||
return DiagnosticExportResult(zipPath: zipPath, bytes: encoded.length);
|
||||
}
|
||||
}
|
||||
99
lib/diagnostics/frame_sampler.dart
Normal file
99
lib/diagnostics/frame_sampler.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
// Frame / hitch sampler for diagnostic packs.
|
||||
|
||||
import 'badnote_log.dart';
|
||||
|
||||
class FrameSample {
|
||||
FrameSample({
|
||||
required this.at,
|
||||
required this.label,
|
||||
required this.ms,
|
||||
this.dropped = false,
|
||||
});
|
||||
|
||||
final DateTime at;
|
||||
final String label;
|
||||
final double ms;
|
||||
final bool dropped;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'at': at.toIso8601String(),
|
||||
'label': label,
|
||||
'ms': ms,
|
||||
'dropped': dropped,
|
||||
};
|
||||
}
|
||||
|
||||
class FrameSampler {
|
||||
FrameSampler._();
|
||||
static final FrameSampler instance = FrameSampler._();
|
||||
|
||||
static const int capacity = 500;
|
||||
final List<FrameSample> _samples = <FrameSample>[];
|
||||
int overBudget = 0;
|
||||
int total = 0;
|
||||
|
||||
/// Budget for a single frame at 60fps.
|
||||
static const double budgetMs = 16.6;
|
||||
|
||||
void record(String label, double ms, {bool dropped = false}) {
|
||||
total++;
|
||||
final over = ms > budgetMs;
|
||||
if (over) overBudget++;
|
||||
final sample = FrameSample(
|
||||
at: DateTime.now(),
|
||||
label: label,
|
||||
ms: ms,
|
||||
dropped: dropped || over,
|
||||
);
|
||||
_samples.add(sample);
|
||||
if (_samples.length > capacity) {
|
||||
_samples.removeRange(0, _samples.length - capacity);
|
||||
}
|
||||
if (over || dropped) {
|
||||
BadNoteLog.instance.warn(
|
||||
LogSubsystem.frame,
|
||||
'slow_frame',
|
||||
fields: {'label': label, 'ms': ms, 'dropped': dropped},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void recordZoom({
|
||||
required double rawScale,
|
||||
required bool scaleDrop,
|
||||
required bool focalDrop,
|
||||
required double focalJumpPx,
|
||||
}) {
|
||||
record(
|
||||
'zoom',
|
||||
scaleDrop || focalDrop ? budgetMs + 1 : 8,
|
||||
dropped: scaleDrop || focalDrop,
|
||||
);
|
||||
BadNoteLog.instance.debug(
|
||||
LogSubsystem.frame,
|
||||
'zoom',
|
||||
fields: {
|
||||
'rawScale': rawScale,
|
||||
'scaleDrop': scaleDrop,
|
||||
'focalDrop': focalDrop,
|
||||
'focalJumpPx': focalJumpPx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> toJsonList() =>
|
||||
_samples.map((s) => s.toJson()).toList(growable: false);
|
||||
|
||||
Map<String, Object?> summary() => {
|
||||
'total': total,
|
||||
'overBudget': overBudget,
|
||||
'budgetMs': budgetMs,
|
||||
'recent': toJsonList(),
|
||||
};
|
||||
|
||||
void reset() {
|
||||
_samples.clear();
|
||||
overBudget = 0;
|
||||
total = 0;
|
||||
}
|
||||
}
|
||||
121
lib/diagnostics/pen_event_ring.dart
Normal file
121
lib/diagnostics/pen_event_ring.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
// Rolling ring of recent pen / arbiter events for diagnostic export.
|
||||
|
||||
class PenEventRecord {
|
||||
PenEventRecord({
|
||||
required this.at,
|
||||
required this.kind,
|
||||
required this.pointerId,
|
||||
this.pressure,
|
||||
this.tiltX,
|
||||
this.tiltY,
|
||||
this.barrel = false,
|
||||
this.eraser = false,
|
||||
this.inverted = false,
|
||||
this.decision,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final DateTime at;
|
||||
final String kind; // down|move|up|hw|arbiter
|
||||
final int pointerId;
|
||||
final double? pressure;
|
||||
final double? tiltX;
|
||||
final double? tiltY;
|
||||
final bool barrel;
|
||||
final bool eraser;
|
||||
final bool inverted;
|
||||
final String? decision; // draw|pan|reject
|
||||
final String? note;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'at': at.toIso8601String(),
|
||||
'kind': kind,
|
||||
'pointerId': pointerId,
|
||||
if (pressure != null) 'pressure': pressure,
|
||||
if (tiltX != null) 'tiltX': tiltX,
|
||||
if (tiltY != null) 'tiltY': tiltY,
|
||||
'barrel': barrel,
|
||||
'eraser': eraser,
|
||||
'inverted': inverted,
|
||||
if (decision != null) 'decision': decision,
|
||||
if (note != null) 'note': note,
|
||||
};
|
||||
}
|
||||
|
||||
class PenEventRing {
|
||||
PenEventRing._();
|
||||
static final PenEventRing instance = PenEventRing._();
|
||||
|
||||
static const int capacity = 2000;
|
||||
final List<PenEventRecord> _events = <PenEventRecord>[];
|
||||
|
||||
void add(PenEventRecord event) {
|
||||
_events.add(event);
|
||||
if (_events.length > capacity) {
|
||||
_events.removeRange(0, _events.length - capacity);
|
||||
}
|
||||
}
|
||||
|
||||
void recordPointer({
|
||||
required String kind,
|
||||
required int pointerId,
|
||||
required String deviceKind,
|
||||
double? pressure,
|
||||
String? decision,
|
||||
String? note,
|
||||
}) {
|
||||
add(PenEventRecord(
|
||||
at: DateTime.now(),
|
||||
kind: kind,
|
||||
pointerId: pointerId,
|
||||
pressure: pressure,
|
||||
decision: decision,
|
||||
note: note ?? deviceKind,
|
||||
));
|
||||
}
|
||||
|
||||
void recordHardware({
|
||||
required bool barrel,
|
||||
required bool eraser,
|
||||
required bool inverted,
|
||||
required double tiltX,
|
||||
required double tiltY,
|
||||
}) {
|
||||
add(PenEventRecord(
|
||||
at: DateTime.now(),
|
||||
kind: 'hw',
|
||||
pointerId: -1,
|
||||
barrel: barrel,
|
||||
eraser: eraser,
|
||||
inverted: inverted,
|
||||
tiltX: tiltX,
|
||||
tiltY: tiltY,
|
||||
));
|
||||
}
|
||||
|
||||
void recordArbiter({
|
||||
required int activeCount,
|
||||
required String deviceKind,
|
||||
required bool draw,
|
||||
required bool fingerDrawing,
|
||||
}) {
|
||||
add(PenEventRecord(
|
||||
at: DateTime.now(),
|
||||
kind: 'arbiter',
|
||||
pointerId: -1,
|
||||
decision: draw ? 'draw' : 'pan',
|
||||
note: 'count=$activeCount kind=$deviceKind finger=$fingerDrawing',
|
||||
));
|
||||
}
|
||||
|
||||
List<PenEventRecord> recent({Duration? window}) {
|
||||
if (window == null) return List.unmodifiable(_events);
|
||||
final cut = DateTime.now().subtract(window);
|
||||
return _events.where((e) => e.at.isAfter(cut)).toList(growable: false);
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> toJsonList({Duration? window}) =>
|
||||
recent(window: window).map((e) => e.toJson()).toList(growable: false);
|
||||
|
||||
void clear() => _events.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user