First commit

This commit is contained in:
2026-04-21 14:40:09 -03:00
commit 9efd27afa5
48 changed files with 4511 additions and 0 deletions

41
lib/app.dart Normal file
View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'config/settings.dart';
import 'export/csv_exporter.dart';
import 'layout/layout_controller.dart';
import 'session/session_controller.dart';
import 'ui/app_scope.dart';
import 'ui/tab_scaffold.dart';
class TelemetryApp extends StatelessWidget {
const TelemetryApp({
super.key,
required this.session,
required this.layout,
required this.settings,
required this.exporter,
});
final SessionController session;
final LayoutController layout;
final Settings settings;
final CsvExporter exporter;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Telemetry Monitor',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
),
home: AppScope(
session: session,
layout: layout,
settings: settings,
exporter: exporter,
child: const TabScaffold(),
),
);
}
}

View File

@@ -0,0 +1,72 @@
/// Compile-time constants and defaults.
///
/// Anything here that the user can override at runtime appears as a default
/// value in `Settings` or `LayoutController`. Anything that's truly fixed
/// (channel count, status count, severity list) lives only here.
class AppConfig {
AppConfig._();
/// Total channels supported by the proto schema. Don't change without
/// updating messages.proto in lockstep.
static const int channelCount = 16;
/// Total status indicators supported by the proto schema.
static const int statusCount = 8;
/// Default per-channel display names.
static const List<String> defaultChannelNames = [
'CH1', 'CH2', 'CH3', 'CH4',
'CH5', 'CH6', 'CH7', 'CH8',
'CH9', 'CH10', 'CH11', 'CH12',
'CH13', 'CH14', 'CH15', 'CH16',
];
/// Default per-status-field display names.
static const List<String> defaultStatusNames = [
'status1', 'status2', 'status3', 'status4',
'status5', 'status6', 'status7', 'status8',
];
/// Default Y range when a channel is fixed-mode and the user hasn't set one.
static const double defaultYMin = 0.0;
static const double defaultYMax = 1.0;
/// Default grid shape on first launch.
static const int defaultGridRows = 2;
static const int defaultGridCols = 2;
/// Default packet buffer capacity (≈ 10 minutes at 1 kHz on native).
static const int defaultPacketBufferCapacityNative = 600000;
/// Reduced default for Web (browser heap pressure).
static const int defaultPacketBufferCapacityWeb = 120000;
/// Default log buffer capacity.
static const int defaultLogBufferCapacity = 5000;
/// Default WebSocket URL on first launch.
static const String defaultWsUrl = 'ws://localhost:9000';
/// Default lookback for the per-frame status snapshot.
static const int defaultStatusLookback = 1000;
/// Default decoder batch interval (native only).
static const Duration defaultDecoderBatchInterval =
Duration(milliseconds: 8);
/// Default reconnect parameters.
static const Duration defaultReconnectInitialDelay =
Duration(milliseconds: 500);
static const Duration defaultReconnectMaxDelay = Duration(seconds: 30);
static const double defaultReconnectBackoffFactor = 2.0;
/// Default mini-log severity filter (Dashboard tab). ERROR + FATAL.
static const Set<int> defaultMiniLogSeverities = {4, 5};
/// Default chart x-window on launch and after Reset View.
static const Duration defaultWindowWidth = Duration(seconds: 10);
/// Hard floor on the x-window — below this, charts become a handful of
/// samples and the UI gets confusing.
static const Duration minWindowWidth = Duration(milliseconds: 10);
}

129
lib/config/settings.dart Normal file
View File

@@ -0,0 +1,129 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app_config.dart';
/// Runtime-tunable parameters that affect the data pipeline.
///
/// Persistence: all keys under the `settings.` prefix in shared_preferences.
/// Display labels (channel names, status names) live in LayoutController, not
/// here, because they're conceptually about how the dashboard looks.
class Settings extends ChangeNotifier {
Settings._({
required this.wsUrl,
required this.packetBufferCapacity,
required this.logBufferCapacity,
required this.decoderBatchInterval,
required this.reconnectInitialDelay,
required this.reconnectMaxDelay,
required this.reconnectBackoffFactor,
required this.miniLogSeverities,
required this.statusLookback,
});
String wsUrl;
int packetBufferCapacity;
int logBufferCapacity;
Duration decoderBatchInterval;
Duration reconnectInitialDelay;
Duration reconnectMaxDelay;
double reconnectBackoffFactor;
Set<int> miniLogSeverities;
int statusLookback;
/// Load from shared_preferences with defaults for any missing keys.
static Future<Settings> load({bool isWeb = false}) async {
final p = await SharedPreferences.getInstance();
return Settings._(
wsUrl: p.getString('settings.wsUrl') ?? AppConfig.defaultWsUrl,
packetBufferCapacity: p.getInt('settings.packetBufferCapacity') ??
(isWeb
? AppConfig.defaultPacketBufferCapacityWeb
: AppConfig.defaultPacketBufferCapacityNative),
logBufferCapacity: p.getInt('settings.logBufferCapacity') ??
AppConfig.defaultLogBufferCapacity,
decoderBatchInterval: Duration(
milliseconds: p.getInt('settings.decoderBatchIntervalMs') ??
AppConfig.defaultDecoderBatchInterval.inMilliseconds,
),
reconnectInitialDelay: Duration(
milliseconds: p.getInt('settings.reconnectInitialDelayMs') ??
AppConfig.defaultReconnectInitialDelay.inMilliseconds,
),
reconnectMaxDelay: Duration(
milliseconds: p.getInt('settings.reconnectMaxDelayMs') ??
AppConfig.defaultReconnectMaxDelay.inMilliseconds,
),
reconnectBackoffFactor: p.getDouble('settings.reconnectBackoffFactor') ??
AppConfig.defaultReconnectBackoffFactor,
miniLogSeverities: (p.getStringList('settings.miniLogSeverities') ??
AppConfig.defaultMiniLogSeverities.map((s) => s.toString()).toList())
.map(int.parse)
.toSet(),
statusLookback: p.getInt('settings.statusLookback') ??
AppConfig.defaultStatusLookback,
);
}
Future<void> save() async {
final p = await SharedPreferences.getInstance();
await p.setString('settings.wsUrl', wsUrl);
await p.setInt('settings.packetBufferCapacity', packetBufferCapacity);
await p.setInt('settings.logBufferCapacity', logBufferCapacity);
await p.setInt('settings.decoderBatchIntervalMs',
decoderBatchInterval.inMilliseconds);
await p.setInt('settings.reconnectInitialDelayMs',
reconnectInitialDelay.inMilliseconds);
await p.setInt('settings.reconnectMaxDelayMs',
reconnectMaxDelay.inMilliseconds);
await p.setDouble(
'settings.reconnectBackoffFactor', reconnectBackoffFactor);
await p.setStringList('settings.miniLogSeverities',
miniLogSeverities.map((s) => s.toString()).toList());
await p.setInt('settings.statusLookback', statusLookback);
notifyListeners();
}
/// Reset every field to its compile-time default. Does not persist; call
/// [save] afterward if you want it persisted.
void restoreDefaults({bool isWeb = false}) {
wsUrl = AppConfig.defaultWsUrl;
packetBufferCapacity = isWeb
? AppConfig.defaultPacketBufferCapacityWeb
: AppConfig.defaultPacketBufferCapacityNative;
logBufferCapacity = AppConfig.defaultLogBufferCapacity;
decoderBatchInterval = AppConfig.defaultDecoderBatchInterval;
reconnectInitialDelay = AppConfig.defaultReconnectInitialDelay;
reconnectMaxDelay = AppConfig.defaultReconnectMaxDelay;
reconnectBackoffFactor = AppConfig.defaultReconnectBackoffFactor;
miniLogSeverities = Set.of(AppConfig.defaultMiniLogSeverities);
statusLookback = AppConfig.defaultStatusLookback;
notifyListeners();
}
/// Take values from [other] (used to apply edits from the Settings dialog).
void copyFrom(Settings other) {
wsUrl = other.wsUrl;
packetBufferCapacity = other.packetBufferCapacity;
logBufferCapacity = other.logBufferCapacity;
decoderBatchInterval = other.decoderBatchInterval;
reconnectInitialDelay = other.reconnectInitialDelay;
reconnectMaxDelay = other.reconnectMaxDelay;
reconnectBackoffFactor = other.reconnectBackoffFactor;
miniLogSeverities = Set.of(other.miniLogSeverities);
statusLookback = other.statusLookback;
notifyListeners();
}
Settings clone() => Settings._(
wsUrl: wsUrl,
packetBufferCapacity: packetBufferCapacity,
logBufferCapacity: logBufferCapacity,
decoderBatchInterval: decoderBatchInterval,
reconnectInitialDelay: reconnectInitialDelay,
reconnectMaxDelay: reconnectMaxDelay,
reconnectBackoffFactor: reconnectBackoffFactor,
miniLogSeverities: Set.of(miniLogSeverities),
statusLookback: statusLookback,
);
}

2
lib/decoder/decoder.dart Normal file
View File

@@ -0,0 +1,2 @@
// Public import for the platform-split decoder.
export 'decoder_isolate.dart' if (dart.library.html) 'decoder_inline.dart';

View File

@@ -0,0 +1,19 @@
import 'dart:async';
import 'dart:typed_data';
import '../proto/messages.pb.dart';
/// Abstract decoder interface. Feeds raw frames in, emits envelopes out.
///
/// On native, the implementation runs in an isolate and batches envelopes.
/// On Web, the implementation decodes synchronously in [feed].
abstract class Decoder {
/// Stream of decoded envelopes (or batches thereof, flattened to per-envelope).
Stream<Envelope> get envelopes;
/// Push a raw frame for decoding. Returns immediately.
void feed(Uint8List frame);
/// Tear down. Stops the isolate (on native) and closes the stream.
Future<void> dispose();
}

View File

@@ -0,0 +1,37 @@
import 'dart:async';
import 'dart:typed_data';
import '../proto/messages.pb.dart';
import 'decoder_base.dart';
export 'decoder_base.dart';
/// Web decoder. Isolates aren't available the same way on Web, so decoding
/// runs synchronously on the main isolate. At 1 kHz with small protobuf
/// messages this is acceptable.
class DecoderInline implements Decoder {
final StreamController<Envelope> _out = StreamController.broadcast();
@override
Stream<Envelope> get envelopes => _out.stream;
@override
void feed(Uint8List frame) {
try {
final env = Envelope.fromBuffer(frame);
_out.add(env);
} catch (_) {
// Malformed packet — silently dropped.
}
}
@override
Future<void> dispose() async {
await _out.close();
}
}
class DecoderImpl extends DecoderInline {
// The inline decoder takes no batchInterval; accept and ignore for API parity.
DecoderImpl({Duration batchInterval = const Duration(milliseconds: 8)});
}

View File

@@ -0,0 +1,118 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:typed_data';
import '../proto/messages.pb.dart';
import 'decoder_base.dart';
export 'decoder_base.dart';
/// Native decoder. Runs protobuf decoding in a worker isolate and batches
/// decoded envelopes back to the main isolate every [batchInterval] to
/// minimize SendPort overhead at 1 kHz.
class DecoderIsolate implements Decoder {
DecoderIsolate({this.batchInterval = const Duration(milliseconds: 8)});
final Duration batchInterval;
final StreamController<Envelope> _out = StreamController.broadcast();
Isolate? _isolate;
SendPort? _toIsolate;
ReceivePort? _fromIsolate;
bool _ready = false;
final List<Uint8List> _pending = [];
@override
Stream<Envelope> get envelopes => _out.stream;
Future<void> start() async {
if (_isolate != null) return;
_fromIsolate = ReceivePort();
final completer = Completer<SendPort>();
_fromIsolate!.listen((dynamic message) {
if (message is SendPort) {
completer.complete(message);
} else if (message is List) {
// Batch of encoded envelopes (List<Uint8List>) sent back from the
// isolate. We could also send already-decoded objects, but Envelope
// is not a transferable type without copy, and re-decoding on the
// main isolate would defeat the purpose. So the isolate sends raw
// bytes of pre-validated envelopes — we trust them and decode here.
// A simpler model: send decoded envelopes via SendPort; protobuf
// objects survive the copy. We use that.
for (final item in message) {
if (item is Envelope) _out.add(item);
}
}
});
_isolate = await Isolate.spawn(
_isolateEntry,
_IsolateInit(
sendPort: _fromIsolate!.sendPort,
batchIntervalMs: batchInterval.inMilliseconds,
),
);
_toIsolate = await completer.future;
_ready = true;
// Drain anything queued before the isolate was ready.
for (final f in _pending) {
_toIsolate!.send(f);
}
_pending.clear();
}
@override
void feed(Uint8List frame) {
if (!_ready) {
_pending.add(frame);
return;
}
// SendPort copies the bytes. At 1 kHz this is ~150 KB/s, negligible.
_toIsolate!.send(frame);
}
@override
Future<void> dispose() async {
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
_fromIsolate?.close();
_fromIsolate = null;
await _out.close();
}
}
/// Public name for `DecoderIsolate` so consumers can write
/// `Decoder d = DecoderImpl(...)` regardless of platform.
class DecoderImpl extends DecoderIsolate {
DecoderImpl({super.batchInterval});
}
class _IsolateInit {
_IsolateInit({required this.sendPort, required this.batchIntervalMs});
final SendPort sendPort;
final int batchIntervalMs;
}
void _isolateEntry(_IsolateInit init) {
final inbox = ReceivePort();
init.sendPort.send(inbox.sendPort);
final List<Envelope> batch = [];
Timer.periodic(Duration(milliseconds: init.batchIntervalMs), (_) {
if (batch.isEmpty) return;
init.sendPort.send(List<Envelope>.from(batch));
batch.clear();
});
inbox.listen((dynamic message) {
if (message is Uint8List) {
try {
final env = Envelope.fromBuffer(message);
batch.add(env);
} catch (_) {
// Malformed packet — silently dropped. The session layer can
// observe this via gaps/PPS mismatch if needed.
}
}
});
}

View File

@@ -0,0 +1,2 @@
// Public import for the platform-split CSV exporter.
export 'csv_exporter_io.dart' if (dart.library.html) 'csv_exporter_web.dart';

View File

@@ -0,0 +1,126 @@
import 'dart:async';
import '../layout/layout_controller.dart';
import '../session/log_buffer.dart';
import '../session/packet_buffer.dart';
typedef ProgressCallback = void Function(double progress);
/// Returned by export operations so the UI can report what happened.
class ExportResult {
ExportResult({required this.path, required this.bytesWritten});
/// On native: filesystem path. On Web: filename.
final String path;
final int bytesWritten;
}
/// Builds CSV row strings. Shared by both platform implementations so the
/// row layout is defined exactly once.
class CsvRowBuilder {
CsvRowBuilder(this.layout);
final LayoutController layout;
/// Header row for data CSV. Disabled channels are omitted; pause and the
/// 8 status fields always appear.
String dataHeader() {
final cols = <String>['timestamp_us'];
for (var ch = 1; ch <= 16; ch++) {
if (layout.configFor(ch).enabled) {
cols.add('ch${ch}_${_safe(layout.configFor(ch).name)}');
}
}
cols.add('pause');
for (var s = 0; s < 8; s++) {
cols.add('status${s + 1}_${_safe(layout.statusNames[s])}');
}
return cols.join(',');
}
/// Row for one packet. Missing values are blank cells.
String dataRow(packet) {
final cells = <String>[];
cells.add(packet.hasTimestampUs() ? packet.timestampUs.toString() : '');
for (var ch = 1; ch <= 16; ch++) {
if (!layout.configFor(ch).enabled) continue;
cells.add(_channelCell(packet, ch));
}
cells.add(packet.hasPause() ? (packet.pause ? '1' : '0') : '');
for (var s = 1; s <= 8; s++) {
cells.add(_statusCell(packet, s));
}
return cells.join(',');
}
String logHeader() => 'timestamp_us,severity,error_number,description';
String logRow(entry) {
final ts = entry.hasTimestampUs() ? entry.timestampUs.toString() : '';
final sev = entry.hasSeverity() ? entry.severity.name : '';
final err = entry.hasErrorNumber() ? entry.errorNumber.toString() : '';
final desc = entry.hasDescription() ? _escape(entry.description) : '';
return '$ts,$sev,$err,$desc';
}
static String _channelCell(packet, int ch) {
switch (ch) {
case 1: return packet.hasCh1() ? packet.ch1.toString() : '';
case 2: return packet.hasCh2() ? packet.ch2.toString() : '';
case 3: return packet.hasCh3() ? packet.ch3.toString() : '';
case 4: return packet.hasCh4() ? packet.ch4.toString() : '';
case 5: return packet.hasCh5() ? packet.ch5.toString() : '';
case 6: return packet.hasCh6() ? packet.ch6.toString() : '';
case 7: return packet.hasCh7() ? packet.ch7.toString() : '';
case 8: return packet.hasCh8() ? packet.ch8.toString() : '';
case 9: return packet.hasCh9() ? packet.ch9.toString() : '';
case 10: return packet.hasCh10() ? packet.ch10.toString() : '';
case 11: return packet.hasCh11() ? packet.ch11.toString() : '';
case 12: return packet.hasCh12() ? packet.ch12.toString() : '';
case 13: return packet.hasCh13() ? packet.ch13.toString() : '';
case 14: return packet.hasCh14() ? packet.ch14.toString() : '';
case 15: return packet.hasCh15() ? packet.ch15.toString() : '';
case 16: return packet.hasCh16() ? packet.ch16.toString() : '';
default: return '';
}
}
static String _statusCell(packet, int s) {
switch (s) {
case 1: return packet.hasStatus1() ? packet.status1.toString() : '';
case 2: return packet.hasStatus2() ? packet.status2.toString() : '';
case 3: return packet.hasStatus3() ? packet.status3.toString() : '';
case 4: return packet.hasStatus4() ? packet.status4.toString() : '';
case 5: return packet.hasStatus5() ? packet.status5.toString() : '';
case 6: return packet.hasStatus6() ? packet.status6.toString() : '';
case 7: return packet.hasStatus7() ? packet.status7.toString() : '';
case 8: return packet.hasStatus8() ? packet.status8.toString() : '';
default: return '';
}
}
/// Strip characters that would break CSV column names.
static String _safe(String s) => s.replaceAll(RegExp(r'[,\s]+'), '_');
/// Escape a free-form field for CSV. Wraps in quotes if needed.
static String _escape(String s) {
if (s.contains(',') || s.contains('"') || s.contains('\n')) {
return '"${s.replaceAll('"', '""')}"';
}
return s;
}
}
/// Common interface implemented by both platform exporters.
abstract class CsvExporter {
/// Export the entire packet buffer as data CSV.
Future<ExportResult> exportData({
required PacketBuffer buffer,
required LayoutController layout,
ProgressCallback? onProgress,
});
/// Export the entire log buffer as log CSV.
Future<ExportResult> exportLog({
required LogBuffer buffer,
ProgressCallback? onProgress,
});
}

View File

@@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../layout/layout_controller.dart';
import '../session/log_buffer.dart';
import '../session/packet_buffer.dart';
import 'csv_exporter_base.dart';
export 'csv_exporter_base.dart';
/// Native CSV exporter: writes to a file in the documents directory.
class CsvExporterImpl implements CsvExporter {
static const int _chunkRows = 1000;
@override
Future<ExportResult> exportData({
required PacketBuffer buffer,
required LayoutController layout,
ProgressCallback? onProgress,
}) async {
final builder = CsvRowBuilder(layout);
final dir = await getApplicationDocumentsDirectory();
final ts = DateTime.now().toIso8601String().replaceAll(':', '-');
final file = File('${dir.path}/telemetry_data_$ts.csv');
final sink = file.openWrite();
sink.writeln(builder.dataHeader());
final total = buffer.length;
var written = 0;
for (var i = 0; i < total; i++) {
sink.writeln(builder.dataRow(buffer[i]));
written++;
if (written % _chunkRows == 0) {
onProgress?.call(written / total);
// Yield to the event loop so the UI stays responsive.
await Future<void>.delayed(Duration.zero);
}
}
await sink.flush();
await sink.close();
onProgress?.call(1.0);
return ExportResult(
path: file.path,
bytesWritten: await file.length(),
);
}
@override
Future<ExportResult> exportLog({
required LogBuffer buffer,
ProgressCallback? onProgress,
}) async {
// Logs don't need a layout to format (no column projection).
final dir = await getApplicationDocumentsDirectory();
final ts = DateTime.now().toIso8601String().replaceAll(':', '-');
final file = File('${dir.path}/telemetry_log_$ts.csv');
final sink = file.openWrite();
final builder = CsvRowBuilder(_NoLayoutPlaceholder());
sink.writeln(builder.logHeader());
final total = buffer.length;
var written = 0;
for (final entry in buffer.iterate()) {
sink.writeln(builder.logRow(entry));
written++;
if (written % _chunkRows == 0) {
onProgress?.call(written / total);
await Future<void>.delayed(Duration.zero);
}
}
await sink.flush();
await sink.close();
onProgress?.call(1.0);
return ExportResult(
path: file.path,
bytesWritten: await file.length(),
);
}
}
/// CsvRowBuilder.logRow doesn't actually need a layout. We pass a placeholder
/// so we don't have to plumb a real one through for log-only exports.
class _NoLayoutPlaceholder implements LayoutController {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:convert';
import 'dart:html' as html;
import 'dart:typed_data';
import '../layout/layout_controller.dart';
import '../session/log_buffer.dart';
import '../session/packet_buffer.dart';
import 'csv_exporter_base.dart';
export 'csv_exporter_base.dart';
/// Web CSV exporter: builds a Blob in memory and triggers a download.
///
/// Yields between chunks so the main thread doesn't lock during large exports.
class CsvExporterImpl implements CsvExporter {
static const int _chunkRows = 1000;
@override
Future<ExportResult> exportData({
required PacketBuffer buffer,
required LayoutController layout,
ProgressCallback? onProgress,
}) async {
final builder = CsvRowBuilder(layout);
final chunks = <String>[];
chunks.add('${builder.dataHeader()}\n');
final total = buffer.length;
final sb = StringBuffer();
var written = 0;
for (var i = 0; i < total; i++) {
sb.writeln(builder.dataRow(buffer[i]));
written++;
if (written % _chunkRows == 0) {
chunks.add(sb.toString());
sb.clear();
onProgress?.call(written / total);
await Future<void>.delayed(Duration.zero);
}
}
if (sb.isNotEmpty) chunks.add(sb.toString());
final ts = DateTime.now().toIso8601String().replaceAll(':', '-');
final filename = 'telemetry_data_$ts.csv';
final bytes = _toBytes(chunks);
_triggerDownload(filename, bytes);
onProgress?.call(1.0);
return ExportResult(path: filename, bytesWritten: bytes.length);
}
@override
Future<ExportResult> exportLog({
required LogBuffer buffer,
ProgressCallback? onProgress,
}) async {
final builder = CsvRowBuilder(_NoLayoutPlaceholder());
final chunks = <String>[];
chunks.add('${builder.logHeader()}\n');
final total = buffer.length;
final sb = StringBuffer();
var written = 0;
for (final entry in buffer.iterate()) {
sb.writeln(builder.logRow(entry));
written++;
if (written % _chunkRows == 0) {
chunks.add(sb.toString());
sb.clear();
onProgress?.call(written / total);
await Future<void>.delayed(Duration.zero);
}
}
if (sb.isNotEmpty) chunks.add(sb.toString());
final ts = DateTime.now().toIso8601String().replaceAll(':', '-');
final filename = 'telemetry_log_$ts.csv';
final bytes = _toBytes(chunks);
_triggerDownload(filename, bytes);
onProgress?.call(1.0);
return ExportResult(path: filename, bytesWritten: bytes.length);
}
Uint8List _toBytes(List<String> chunks) {
final encoder = utf8.encoder;
final encoded = chunks.map(encoder.convert).toList();
final total = encoded.fold<int>(0, (s, c) => s + c.length);
final out = Uint8List(total);
var off = 0;
for (final c in encoded) {
out.setRange(off, off + c.length, c);
off += c.length;
}
return out;
}
void _triggerDownload(String filename, Uint8List bytes) {
final blob = html.Blob([bytes], 'text/csv');
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..download = filename
..style.display = 'none';
html.document.body!.append(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
}
}
class _NoLayoutPlaceholder implements LayoutController {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -0,0 +1,10 @@
// Re-export of AppConfig defaults under a name that doesn't pull in
// pkg-relative cycles when ChartConfig is constructed in tests.
export '../config/app_config.dart' show AppConfig;
import '../config/app_config.dart';
class AppConfigSafe {
AppConfigSafe._();
static const double defaultYMin = AppConfig.defaultYMin;
static const double defaultYMax = AppConfig.defaultYMax;
}

View File

@@ -0,0 +1,60 @@
import 'app_config_safe.dart';
enum YMode { auto, fixed, userZoomed }
YMode yModeFromString(String s) {
switch (s) {
case 'fixed': return YMode.fixed;
case 'userZoomed': return YMode.userZoomed;
case 'auto':
default: return YMode.auto;
}
}
String yModeToString(YMode m) {
switch (m) {
case YMode.auto: return 'auto';
case YMode.fixed: return 'fixed';
case YMode.userZoomed: return 'userZoomed';
}
}
/// Per-channel display configuration.
///
/// `channel` is the proto channel index (1..16). `enabled` toggles UI
/// visibility — disabled channels still record to the buffer and to CSV.
class ChartConfig {
ChartConfig({
required this.channel,
required this.name,
this.enabled = true,
this.yMode = YMode.auto,
this.yMin = AppConfigSafe.defaultYMin,
this.yMax = AppConfigSafe.defaultYMax,
});
final int channel;
String name;
bool enabled;
YMode yMode;
double yMin;
double yMax;
Map<String, dynamic> toJson() => {
'channel': channel,
'name': name,
'enabled': enabled,
'yMode': yModeToString(yMode),
'yMin': yMin,
'yMax': yMax,
};
static ChartConfig fromJson(Map<String, dynamic> j) => ChartConfig(
channel: j['channel'] as int,
name: j['name'] as String,
enabled: j['enabled'] as bool? ?? true,
yMode: yModeFromString(j['yMode'] as String? ?? 'auto'),
yMin: (j['yMin'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMin,
yMax: (j['yMax'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMax,
);
}

View File

@@ -0,0 +1,77 @@
/// Grid shape and per-cell channel assignments.
///
/// Cell numbering is 1..N in row-major order. A cell may be unassigned
/// (channel = null), in which case it renders as empty.
class GridConfig {
GridConfig({
required this.rows,
required this.cols,
List<int?>? cellChannels,
}) : cellChannels =
cellChannels ?? List<int?>.filled(rows * cols, null);
int rows;
int cols;
/// Length = rows * cols. Element is the proto channel index (1..16) or null.
List<int?> cellChannels;
int get cellCount => rows * cols;
/// 1-based cell number → channel, or null if unassigned.
int? channelForCell(int cellNumber) {
if (cellNumber < 1 || cellNumber > cellCount) return null;
return cellChannels[cellNumber - 1];
}
/// Set the channel assigned to a 1-based cell. If [channel] is already
/// assigned to a different cell, that cell is cleared (auto-swap).
void assign(int cellNumber, int? channel) {
if (cellNumber < 1 || cellNumber > cellCount) return;
if (channel != null) {
for (var i = 0; i < cellChannels.length; i++) {
if (cellChannels[i] == channel) cellChannels[i] = null;
}
}
cellChannels[cellNumber - 1] = channel;
}
/// Resize the grid. Channel assignments to cells beyond the new range
/// are dropped.
void resize(int newRows, int newCols) {
final newCells = List<int?>.filled(newRows * newCols, null);
final keep = newCells.length < cellChannels.length
? newCells.length
: cellChannels.length;
for (var i = 0; i < keep; i++) {
newCells[i] = cellChannels[i];
}
rows = newRows;
cols = newCols;
cellChannels = newCells;
}
/// Auto-assign: fill cells with the lowest-numbered enabled channels.
void autoAssign(List<int> enabledChannels) {
cellChannels = List<int?>.filled(cellCount, null);
final n = enabledChannels.length < cellCount
? enabledChannels.length
: cellCount;
for (var i = 0; i < n; i++) {
cellChannels[i] = enabledChannels[i];
}
}
Map<String, dynamic> toJson() => {
'rows': rows,
'cols': cols,
'cellChannels': cellChannels,
};
static GridConfig fromJson(Map<String, dynamic> j) => GridConfig(
rows: j['rows'] as int,
cols: j['cols'] as int,
cellChannels: (j['cellChannels'] as List)
.map((e) => e as int?)
.toList(),
);
}

View File

@@ -0,0 +1,202 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import 'chart_config.dart';
import 'grid_config.dart';
/// Owns the dashboard's display configuration.
///
/// - Grid shape and channel-to-cell assignment (GridConfig).
/// - Per-channel display config (ChartConfig × 16).
/// - Status indicator display names (List<String> × 8).
///
/// Persistence: shared_preferences under the `layout.` prefix.
class LayoutController extends ChangeNotifier {
LayoutController._({
required this.grid,
required this.charts,
required this.statusNames,
});
GridConfig grid;
/// Indexed 0..15 corresponding to channels 1..16.
List<ChartConfig> charts;
/// Indexed 0..7 corresponding to status1..status8.
List<String> statusNames;
static Future<LayoutController> load() async {
final p = await SharedPreferences.getInstance();
final gridJson = p.getString('layout.grid');
final chartsJson = p.getString('layout.charts');
final statusJson = p.getString('layout.statusNames');
final grid = gridJson != null
? GridConfig.fromJson(jsonDecode(gridJson) as Map<String, dynamic>)
: GridConfig(
rows: AppConfig.defaultGridRows,
cols: AppConfig.defaultGridCols,
);
final charts = chartsJson != null
? (jsonDecode(chartsJson) as List)
.map((e) => ChartConfig.fromJson(e as Map<String, dynamic>))
.toList()
: List<ChartConfig>.generate(
AppConfig.channelCount,
(i) => ChartConfig(
channel: i + 1,
name: AppConfig.defaultChannelNames[i],
enabled: i < AppConfig.defaultGridRows * AppConfig.defaultGridCols,
),
);
final statusNames = statusJson != null
? (jsonDecode(statusJson) as List).cast<String>()
: List<String>.from(AppConfig.defaultStatusNames);
final ctrl = LayoutController._(
grid: grid,
charts: charts,
statusNames: statusNames,
);
// First-run convenience: auto-assign enabled channels to grid cells if
// the saved grid is empty.
if (gridJson == null) {
ctrl.grid.autoAssign(
ctrl.charts.where((c) => c.enabled).map((c) => c.channel).toList(),
);
}
return ctrl;
}
Future<void> save() async {
final p = await SharedPreferences.getInstance();
await p.setString('layout.grid', jsonEncode(grid.toJson()));
await p.setString(
'layout.charts',
jsonEncode(charts.map((c) => c.toJson()).toList()),
);
await p.setString('layout.statusNames', jsonEncode(statusNames));
notifyListeners();
}
/// Channel 1..16 → ChartConfig.
ChartConfig configFor(int channel) => charts[channel - 1];
/// Replace state from another LayoutController (used by the Layout dialog
/// when applying edits from a working copy).
void copyFrom(LayoutController other) {
grid = GridConfig(
rows: other.grid.rows,
cols: other.grid.cols,
cellChannels: List<int?>.from(other.grid.cellChannels),
);
charts = other.charts
.map((c) => ChartConfig(
channel: c.channel,
name: c.name,
enabled: c.enabled,
yMode: c.yMode,
yMin: c.yMin,
yMax: c.yMax,
))
.toList();
statusNames = List<String>.from(other.statusNames);
notifyListeners();
}
LayoutController clone() {
return LayoutController._(
grid: GridConfig(
rows: grid.rows,
cols: grid.cols,
cellChannels: List<int?>.from(grid.cellChannels),
),
charts: charts
.map((c) => ChartConfig(
channel: c.channel,
name: c.name,
enabled: c.enabled,
yMode: c.yMode,
yMin: c.yMin,
yMax: c.yMax,
))
.toList(),
statusNames: List<String>.from(statusNames),
);
}
/// Mutators that notify on change.
void setChannelEnabled(int channel, bool enabled) {
final c = configFor(channel);
if (c.enabled == enabled) return;
c.enabled = enabled;
if (!enabled) {
// Also clear from any grid cell.
for (var i = 0; i < grid.cellChannels.length; i++) {
if (grid.cellChannels[i] == channel) grid.cellChannels[i] = null;
}
}
notifyListeners();
}
void setChannelName(int channel, String name) {
final c = configFor(channel);
if (c.name == name) return;
c.name = name;
notifyListeners();
}
void setYMode(int channel, YMode mode) {
final c = configFor(channel);
if (c.yMode == mode) return;
c.yMode = mode;
notifyListeners();
}
void setYRange(int channel, double yMin, double yMax) {
final c = configFor(channel);
if (c.yMin == yMin && c.yMax == yMax) return;
c.yMin = yMin;
c.yMax = yMax;
notifyListeners();
}
/// Mouse-wheel Y zoom on a chart. Switches mode to userZoomed and updates
/// the range, keeping the pivot screen-Y fixed.
void zoomY({
required int channel,
required double pivotValue,
required double factor,
}) {
final c = configFor(channel);
final span = c.yMax - c.yMin;
final newSpan = span * factor;
final fraction = (pivotValue - c.yMin) / span;
c.yMin = pivotValue - newSpan * fraction;
c.yMax = c.yMin + newSpan;
c.yMode = YMode.userZoomed;
notifyListeners();
}
void setStatusName(int statusIdx, String name) {
if (statusNames[statusIdx] == name) return;
statusNames[statusIdx] = name;
notifyListeners();
}
void setGridSize(int rows, int cols) {
grid.resize(rows, cols);
notifyListeners();
}
void assignCellToChannel(int cellNumber, int? channel) {
grid.assign(cellNumber, channel);
notifyListeners();
}
}

45
lib/main.dart Normal file
View File

@@ -0,0 +1,45 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'app.dart';
import 'config/settings.dart';
import 'decoder/decoder.dart';
import 'export/csv_exporter.dart';
import 'layout/layout_controller.dart';
import 'session/session_controller.dart';
import 'transport/websocket_transport.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final settings = await Settings.load(isWeb: kIsWeb);
final layout = await LayoutController.load();
final transport = WebSocketTransport(
initialReconnectDelay: settings.reconnectInitialDelay,
maxReconnectDelay: settings.reconnectMaxDelay,
backoffFactor: settings.reconnectBackoffFactor,
);
final decoder = DecoderImpl(batchInterval: settings.decoderBatchInterval);
// Native isolate decoders need an explicit start.
if (decoder is DecoderIsolate) {
await decoder.start();
}
final session = SessionController(
transport: transport,
decoder: decoder,
settings: settings,
);
await session.start();
final exporter = CsvExporterImpl();
runApp(TelemetryApp(
session: session,
layout: layout,
settings: settings,
exporter: exporter,
));
}

227
lib/session/decimator.dart Normal file
View File

@@ -0,0 +1,227 @@
import 'dart:collection';
import '../proto/messages.pb.dart';
import 'packet_buffer.dart';
/// One column of decimated data. `hasData=false` means no packet contributed
/// a value for this channel in the column's time slice.
class DecimatedColumn {
const DecimatedColumn(this.min, this.max, this.hasData);
final double min;
final double max;
final bool hasData;
}
/// Classification of a missing-data segment.
class GapInfo {
GapInfo(this.hatchedRanges, this.markerPixels);
/// Pixel-x ranges (start, end) where data is missing for ≥1 pixel column.
final List<({int startPx, int endPx})> hatchedRanges;
/// Pixel-x positions where missing-data spans less than 1 column. Deduped.
final Set<int> markerPixels;
}
/// Per-channel min/max decimation with LRU cache.
///
/// Each cache entry is keyed on (startUs, endUs, pixelWidth). Live mode
/// hits the cache continuously since prior columns don't change as the
/// window slides — the right edge re-decimates only the trailing column.
/// Pan/zoom thrash the cache; LRU keeps memory bounded.
class Decimator {
Decimator({this.maxEntriesPerChannel = 64});
final int maxEntriesPerChannel;
// channel -> LRU map (LinkedHashMap iterates in insertion order).
final Map<int, LinkedHashMap<_Key, List<DecimatedColumn>>> _columnCache = {};
final Map<int, LinkedHashMap<_Key, GapInfo>> _gapCache = {};
void clear() {
_columnCache.clear();
_gapCache.clear();
}
/// Forget cached entries for the trailing edge (live mode invalidation).
/// Cheap heuristic: just clear everything for now. The cost of a full
/// rebuild is one decimation pass, which is fast.
void invalidateTail() => clear();
List<DecimatedColumn> decimate({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
if (pixelWidth <= 0) return const [];
final key = _Key(startUs, endUs, pixelWidth);
final lru = _columnCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final result = _runDecimate(channel, buffer, startUs, endUs, pixelWidth);
lru[key] = result;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return result;
}
List<DecimatedColumn> _runDecimate(
int channel,
PacketBuffer buffer,
int startUs,
int endUs,
int pixelWidth,
) {
final widthUs = endUs - startUs;
if (widthUs <= 0) return const [];
final pixUs = widthUs / pixelWidth;
final mins = List<double>.filled(pixelWidth, double.infinity);
final maxs = List<double>.filled(pixelWidth, double.negativeInfinity);
final has = List<bool>.filled(pixelWidth, false);
for (final p in buffer.iterateRange(startUs, endUs)) {
final v = _channelValue(p, channel);
if (v == null) continue;
final tsUs = p.timestampUs.toInt();
var col = ((tsUs - startUs) / pixUs).floor();
if (col < 0) col = 0;
if (col >= pixelWidth) col = pixelWidth - 1;
if (!has[col]) {
mins[col] = v;
maxs[col] = v;
has[col] = true;
} else {
if (v < mins[col]) mins[col] = v;
if (v > maxs[col]) maxs[col] = v;
}
}
return List.generate(
pixelWidth,
(i) => DecimatedColumn(mins[i], maxs[i], has[i]),
);
}
/// Compute gap segments from a decimated column array.
///
/// Rule (per design):
/// - A gap of ≥ 1 pixel column → hatched rectangle spanning those columns.
/// - A gap shorter than 1 pixel column → thin vertical marker line.
/// If two markers fall on the same pixel-x, only one is drawn.
GapInfo computeGaps({
required int channel,
required PacketBuffer buffer,
required int startUs,
required int endUs,
required int pixelWidth,
}) {
final key = _Key(startUs, endUs, pixelWidth);
final lru = _gapCache.putIfAbsent(channel, () => LinkedHashMap());
final cached = lru.remove(key);
if (cached != null) {
lru[key] = cached;
return cached;
}
final cols = decimate(
channel: channel,
buffer: buffer,
startUs: startUs,
endUs: endUs,
pixelWidth: pixelWidth,
);
final hatched = <({int startPx, int endPx})>[];
int? gapStart;
for (var i = 0; i < cols.length; i++) {
if (!cols[i].hasData) {
gapStart ??= i;
} else if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: i - 1));
gapStart = null;
}
}
if (gapStart != null) {
hatched.add((startPx: gapStart, endPx: cols.length - 1));
}
// Sub-pixel markers: scan adjacent present packets in this channel that
// are separated by less than one pixel column.
final markers = <int>{};
final widthUs = endUs - startUs;
final pixUs = widthUs / pixelWidth;
DataPacket? prevWithValue;
for (final p in buffer.iterateRange(startUs, endUs)) {
final v = _channelValue(p, channel);
if (v == null) continue;
if (prevWithValue != null) {
final dtUs = p.timestampUs.toInt() - prevWithValue.timestampUs.toInt();
// A "gap" is a discontinuity larger than one expected sample period.
// We don't know the nominal period here, so treat any inter-packet
// dt > 1.5× the median... actually no, the design only marks gaps
// when an entire column has no data. Sub-pixel markers come from
// *missing fields between adjacent packets* whose total dt < pixUs.
// If both packets sit in the same column AND have a dt jump that
// we want to surface, that's only meaningful when the field-presence
// pattern shows a gap — which the column-based detection already
// covers. So markers here capture the case where a multi-packet
// gap is shorter than a pixel column: same-column missing field.
if (dtUs > 0 && dtUs < pixUs * 0.5) {
// This is an unusually dense pair — not a gap, skip.
}
}
prevWithValue = p;
}
// The marker logic above is a placeholder for the density check.
// Real sub-pixel-gap detection requires knowledge of expected packet
// cadence; that's a refinement we can add when we have real data.
final info = GapInfo(hatched, markers);
lru[key] = info;
if (lru.length > maxEntriesPerChannel) {
lru.remove(lru.keys.first);
}
return info;
}
static double? _channelValue(DataPacket p, int channel) {
switch (channel) {
case 1: return p.hasCh1() ? p.ch1 : null;
case 2: return p.hasCh2() ? p.ch2 : null;
case 3: return p.hasCh3() ? p.ch3 : null;
case 4: return p.hasCh4() ? p.ch4 : null;
case 5: return p.hasCh5() ? p.ch5 : null;
case 6: return p.hasCh6() ? p.ch6 : null;
case 7: return p.hasCh7() ? p.ch7 : null;
case 8: return p.hasCh8() ? p.ch8 : null;
case 9: return p.hasCh9() ? p.ch9 : null;
case 10: return p.hasCh10() ? p.ch10 : null;
case 11: return p.hasCh11() ? p.ch11 : null;
case 12: return p.hasCh12() ? p.ch12 : null;
case 13: return p.hasCh13() ? p.ch13 : null;
case 14: return p.hasCh14() ? p.ch14 : null;
case 15: return p.hasCh15() ? p.ch15 : null;
case 16: return p.hasCh16() ? p.ch16 : null;
default: return null;
}
}
}
class _Key {
const _Key(this.startUs, this.endUs, this.pixelWidth);
final int startUs;
final int endUs;
final int pixelWidth;
@override
bool operator ==(Object other) =>
other is _Key &&
startUs == other.startUs &&
endUs == other.endUs &&
pixelWidth == other.pixelWidth;
@override
int get hashCode => Object.hash(startUs, endUs, pixelWidth);
}

View File

@@ -0,0 +1,52 @@
import '../proto/messages.pb.dart';
/// Fixed-capacity ring buffer of log entries. Same semantics as PacketBuffer
/// but tuned for log throughput (much lower than data).
class LogBuffer {
LogBuffer({required this.capacity}) : _storage = List.filled(capacity, null);
int capacity;
List<LogPacket?> _storage;
int _head = 0;
int _length = 0;
int get length => _length;
bool get isEmpty => _length == 0;
void add(LogPacket entry) {
_storage[_head] = entry;
_head = (_head + 1) % capacity;
if (_length < capacity) _length++;
}
void clear() {
_head = 0;
_length = 0;
for (var i = 0; i < _storage.length; i++) {
_storage[i] = null;
}
}
void resize(int newCapacity) {
if (newCapacity == capacity) return;
final preserve = newCapacity < _length ? newCapacity : _length;
final newStorage = List<LogPacket?>.filled(newCapacity, null);
final start = (_head - preserve + capacity) % capacity;
for (var i = 0; i < preserve; i++) {
newStorage[i] = _storage[(start + i) % capacity];
}
_storage = newStorage;
capacity = newCapacity;
_head = preserve % newCapacity;
_length = preserve;
}
/// Iterate from oldest to newest.
Iterable<LogPacket> iterate() sync* {
if (_length == 0) return;
final start = (_head - _length + capacity) % capacity;
for (var i = 0; i < _length; i++) {
yield _storage[(start + i) % capacity]!;
}
}
}

View File

@@ -0,0 +1,117 @@
import '../proto/messages.pb.dart';
/// Fixed-capacity ring buffer of DataPackets.
///
/// O(1) `add`, O(log N) `findIndexAtOrBefore` via binary search.
/// Iteration over a time range is O(K + log N) where K is the number of
/// packets in the range.
///
/// This is the single source of truth for sample data. The CSV exporter,
/// the chart decimator, and the per-frame status snapshot all read from
/// this buffer; no parallel storage exists.
class PacketBuffer {
PacketBuffer({required this.capacity}) : _storage = List.filled(capacity, null);
int capacity;
List<DataPacket?> _storage;
int _head = 0; // Next write index.
int _length = 0;
int get length => _length;
bool get isEmpty => _length == 0;
bool get isNotEmpty => _length > 0;
/// Index in storage of the oldest packet, or -1 if empty.
int get _firstIndex => _length == 0
? -1
: (_head - _length + capacity) % capacity;
/// Add a packet. If the buffer is full, the oldest packet is evicted.
///
/// Packets are expected to arrive monotonically by timestamp. Out-of-order
/// packets are still stored in arrival order; the buffer doesn't sort.
void add(DataPacket p) {
_storage[_head] = p;
_head = (_head + 1) % capacity;
if (_length < capacity) _length++;
}
void clear() {
_head = 0;
_length = 0;
for (var i = 0; i < _storage.length; i++) {
_storage[i] = null;
}
}
/// Resize to a new capacity. Preserves the most recent packets if shrinking.
void resize(int newCapacity) {
if (newCapacity == capacity) return;
final preserve = newCapacity < _length ? newCapacity : _length;
final newStorage = List<DataPacket?>.filled(newCapacity, null);
final start = (_head - preserve + capacity) % capacity;
for (var i = 0; i < preserve; i++) {
newStorage[i] = _storage[(start + i) % capacity];
}
_storage = newStorage;
capacity = newCapacity;
_head = preserve % newCapacity;
_length = preserve;
}
DataPacket? get oldest => _length == 0 ? null : _storage[_firstIndex];
DataPacket? get newest =>
_length == 0 ? null : _storage[(_head - 1 + capacity) % capacity];
/// Get the i-th packet in chronological order (0 = oldest).
DataPacket operator [](int i) {
assert(i >= 0 && i < _length);
return _storage[(_firstIndex + i) % capacity]!;
}
/// Binary search: largest index whose timestamp is ≤ [timestampUs].
/// Returns -1 if no such index. Packets without a timestamp are skipped.
int findIndexAtOrBefore(int timestampUs) {
if (_length == 0) return -1;
var lo = 0, hi = _length - 1, result = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final ts = this[mid].timestampUs.toInt();
if (ts <= timestampUs) {
result = mid;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/// Binary search: smallest index whose timestamp is ≥ [timestampUs].
int findIndexAtOrAfter(int timestampUs) {
if (_length == 0) return -1;
var lo = 0, hi = _length - 1, result = -1;
while (lo <= hi) {
final mid = (lo + hi) >> 1;
final ts = this[mid].timestampUs.toInt();
if (ts >= timestampUs) {
result = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return result;
}
/// Iterate packets in `[startUs, endUs]` (inclusive).
Iterable<DataPacket> iterateRange(int startUs, int endUs) sync* {
final start = findIndexAtOrAfter(startUs);
if (start == -1) return;
for (var i = start; i < _length; i++) {
final p = this[i];
if (p.timestampUs.toInt() > endUs) break;
yield p;
}
}
}

View File

@@ -0,0 +1,34 @@
import 'dart:collection';
/// Sliding-window packets-per-second counter.
///
/// Measures wall-clock arrival rate, which intentionally differs from the
/// timestamp_us field in the packet (server time). Network jitter makes the
/// two diverge.
class PpsCounter {
PpsCounter({this.window = const Duration(seconds: 1)});
final Duration window;
final Queue<DateTime> _arrivals = Queue();
void recordArrival([DateTime? now]) {
_arrivals.add(now ?? DateTime.now());
_evictOld(now);
}
/// Current PPS measurement.
double current([DateTime? now]) {
_evictOld(now);
final secs = window.inMilliseconds / 1000.0;
return _arrivals.length / secs;
}
void reset() => _arrivals.clear();
void _evictOld(DateTime? now) {
final cutoff = (now ?? DateTime.now()).subtract(window);
while (_arrivals.isNotEmpty && _arrivals.first.isBefore(cutoff)) {
_arrivals.removeFirst();
}
}
}

View File

@@ -0,0 +1,175 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../config/settings.dart';
import '../decoder/decoder.dart';
import '../proto/messages.pb.dart';
import '../transport/connection_state.dart';
import '../transport/websocket_transport.dart';
import 'decimator.dart';
import 'log_buffer.dart';
import 'packet_buffer.dart';
import 'pps_counter.dart';
import 'status_snapshot.dart';
import 'view_state.dart';
/// Central state owner. Wires transport → decoder → buffers → notifiers.
///
/// Lifecycle: construct → call `start()` → use → call `dispose()`.
class SessionController {
SessionController({
required this.transport,
required this.decoder,
required this.settings,
}) : packets = PacketBuffer(capacity: settings.packetBufferCapacity),
logs = LogBuffer(capacity: settings.logBufferCapacity),
viewState = ViewState();
final WebSocketTransport transport;
final Decoder decoder;
final Settings settings;
final PacketBuffer packets;
final LogBuffer logs;
final ViewState viewState;
final Decimator decimator = Decimator();
final PpsCounter _pps = PpsCounter();
final ValueNotifier<int> _frameTick = ValueNotifier(0);
final ValueNotifier<int> _logTick = ValueNotifier(0);
final ValueNotifier<StatusSnapshot> _snapshot =
ValueNotifier(StatusSnapshot.empty());
ValueListenable<int> get frameTick => _frameTick;
ValueListenable<int> get logTick => _logTick;
ValueListenable<StatusSnapshot> get statusSnapshot => _snapshot;
ValueListenable<WsConnectionState> get connectionState => transport.state;
StreamSubscription<Envelope>? _envSub;
Ticker? _ticker;
Future<void> start() async {
_envSub = decoder.envelopes.listen(_onEnvelope);
transport.frames.listen((bytes) => decoder.feed(bytes));
_ticker = Ticker(_onTick)..start();
await transport.connect(settings.wsUrl);
}
void _onEnvelope(Envelope env) {
if (env.hasData()) {
packets.add(env.data);
_pps.recordArrival();
decimator.invalidateTail();
} else if (env.hasLog()) {
logs.add(env.log);
_logTick.value++;
}
}
void _onTick(Duration elapsed) {
_snapshot.value = _computeSnapshot();
_frameTick.value++;
}
StatusSnapshot _computeSnapshot() {
final lookback = settings.statusLookback;
final statusValues = List<int?>.filled(8, null);
bool? protoPaused;
int resolved = 0;
// Walk backward from newest, up to `lookback` packets, stop when all 9
// fields are resolved.
final n = packets.length;
final scan = lookback < n ? lookback : n;
for (var i = 0; i < scan && resolved < 9; i++) {
final p = packets[n - 1 - i];
for (var s = 0; s < 8; s++) {
if (statusValues[s] == null && _hasStatus(p, s + 1)) {
statusValues[s] = _getStatus(p, s + 1);
resolved++;
}
}
if (protoPaused == null && p.hasPause()) {
protoPaused = p.pause;
resolved++;
}
}
return StatusSnapshot(
connection: transport.state.value,
pps: _pps.current(),
statusValues: statusValues,
protoPaused: protoPaused,
);
}
bool get isPaused =>
viewState.userPaused || (_snapshot.value.protoPaused ?? false);
PauseSource get pauseSource {
final u = viewState.userPaused;
final p = _snapshot.value.protoPaused ?? false;
if (u && p) return PauseSource.both;
if (u) return PauseSource.user;
if (p) return PauseSource.proto;
return PauseSource.none;
}
void clearAll() {
packets.clear();
logs.clear();
decimator.clear();
_pps.reset();
viewState.goLive();
_logTick.value++;
}
Future<void> reconnect() async {
await transport.connect(settings.wsUrl);
}
Future<void> dispose() async {
_ticker?.dispose();
_ticker = null;
await _envSub?.cancel();
_envSub = null;
_frameTick.dispose();
_logTick.dispose();
_snapshot.dispose();
await decoder.dispose();
await transport.dispose();
viewState.dispose();
}
// ---- field accessors (mirrored from Decimator for status fields) ----
static bool _hasStatus(DataPacket p, int idx) {
switch (idx) {
case 1: return p.hasStatus1();
case 2: return p.hasStatus2();
case 3: return p.hasStatus3();
case 4: return p.hasStatus4();
case 5: return p.hasStatus5();
case 6: return p.hasStatus6();
case 7: return p.hasStatus7();
case 8: return p.hasStatus8();
default: return false;
}
}
static int _getStatus(DataPacket p, int idx) {
switch (idx) {
case 1: return p.status1;
case 2: return p.status2;
case 3: return p.status3;
case 4: return p.status4;
case 5: return p.status5;
case 6: return p.status6;
case 7: return p.status7;
case 8: return p.status8;
default: return 0;
}
}
}

View File

@@ -0,0 +1,32 @@
import '../transport/connection_state.dart';
/// Per-frame snapshot of derived state shown in the status bar.
///
/// All status-related values are *derived* from the packet buffer by walking
/// backward through up to `settings.statusLookback` packets. Fields not seen
/// within the lookback window are reported as null and rendered as "unknown".
class StatusSnapshot {
const StatusSnapshot({
required this.connection,
required this.pps,
required this.statusValues,
required this.protoPaused,
});
final WsConnectionState connection;
final double pps;
/// Length 8. Element is null = "not seen in lookback window".
final List<int?> statusValues;
/// Null = "not seen in lookback window". Treated as false for pause logic.
final bool? protoPaused;
factory StatusSnapshot.empty({
WsConnectionState connection = WsConnectionState.disconnected,
}) =>
StatusSnapshot(
connection: connection,
pps: 0,
statusValues: List<int?>.filled(8, null),
protoPaused: null,
);
}

130
lib/session/view_state.dart Normal file
View File

@@ -0,0 +1,130 @@
import 'package:flutter/foundation.dart';
enum ViewAnchorMode { followLive, absolute }
enum PauseSource { none, user, proto, both }
/// Window state for all charts. X-axis is shared globally.
///
/// Does NOT hold proto-derived pause state — that comes from the per-frame
/// status snapshot (see SessionController). Composing the two flags happens
/// in SessionController.isPaused.
class ViewState extends ChangeNotifier {
ViewState({
Duration windowWidth = const Duration(seconds: 10),
Duration minWindow = const Duration(milliseconds: 10),
}) : _windowWidth = windowWidth,
_minWindow = minWindow;
Duration _windowWidth;
final Duration _minWindow;
ViewAnchorMode _anchorMode = ViewAnchorMode.followLive;
int _absoluteStartUs = 0;
bool _userPaused = false;
Duration get windowWidth => _windowWidth;
ViewAnchorMode get anchorMode => _anchorMode;
int get absoluteStartUs => _absoluteStartUs;
bool get userPaused => _userPaused;
Duration get minWindow => _minWindow;
set userPaused(bool v) {
if (_userPaused == v) return;
_userPaused = v;
notifyListeners();
}
void togglePause() => userPaused = !_userPaused;
/// Compute the visible time range given current state.
///
/// `nowUs` is wall-clock (or last-packet-time, depending on caller choice).
/// `oldestUs` and `newestUs` bound the buffer; the window is clamped so it
/// cannot extend earlier than `oldestUs`.
({int startUs, int endUs}) currentWindow({
required int nowUs,
required int oldestUs,
required int newestUs,
}) {
final widthUs = _windowWidth.inMicroseconds;
int startUs, endUs;
if (_anchorMode == ViewAnchorMode.followLive) {
endUs = nowUs;
startUs = endUs - widthUs;
} else {
startUs = _absoluteStartUs;
endUs = startUs + widthUs;
}
// Clamp left edge to buffer start.
if (startUs < oldestUs) {
final shift = oldestUs - startUs;
startUs += shift;
endUs += shift;
}
return (startUs: startUs, endUs: endUs);
}
/// Set the window width, clamping to [minWindow, maxWindow].
void setWindowWidth(Duration width, {required Duration maxWindow}) {
var clamped = width;
if (clamped < _minWindow) clamped = _minWindow;
if (clamped > maxWindow) clamped = maxWindow;
if (clamped == _windowWidth) return;
_windowWidth = clamped;
notifyListeners();
}
/// Zoom centered on a pivot timestamp. Factor < 1 zooms in.
void zoomAt({
required int pivotUs,
required double factor,
required Duration maxWindow,
required int oldestUs,
required int newestUs,
}) {
final newWidthUs = (_windowWidth.inMicroseconds * factor).round();
final newWidth = Duration(microseconds: newWidthUs);
setWindowWidth(newWidth, maxWindow: maxWindow);
if (_anchorMode == ViewAnchorMode.absolute) {
// Keep pivot at the same screen position.
final width = _windowWidth.inMicroseconds;
final fraction = (pivotUs - _absoluteStartUs) /
(newWidthUs / factor); // old width
_absoluteStartUs = (pivotUs - (fraction * width)).round();
_clampAnchor(oldestUs: oldestUs, newestUs: newestUs);
}
// In followLive mode the pivot is always near `now`; nothing to adjust.
notifyListeners();
}
/// Pan by a delta. Switches to absolute anchor if not already.
void panBy(Duration delta, {required int oldestUs, required int newestUs}) {
if (_anchorMode == ViewAnchorMode.followLive) {
// Snap to current window position before entering scrollback.
_absoluteStartUs = newestUs - _windowWidth.inMicroseconds;
_anchorMode = ViewAnchorMode.absolute;
}
_absoluteStartUs += delta.inMicroseconds;
_clampAnchor(oldestUs: oldestUs, newestUs: newestUs);
notifyListeners();
}
void goLive() {
if (_anchorMode == ViewAnchorMode.followLive) return;
_anchorMode = ViewAnchorMode.followLive;
notifyListeners();
}
void resetView({Duration defaultWidth = const Duration(seconds: 10)}) {
_anchorMode = ViewAnchorMode.followLive;
_windowWidth = defaultWidth;
notifyListeners();
}
void _clampAnchor({required int oldestUs, required int newestUs}) {
final width = _windowWidth.inMicroseconds;
final maxStart = newestUs - width;
if (_absoluteStartUs > maxStart) _absoluteStartUs = maxStart;
if (_absoluteStartUs < oldestUs) _absoluteStartUs = oldestUs;
}
}

View File

@@ -0,0 +1,12 @@
/// State of the WebSocket connection. Reported by `WebSocketTransport`.
///
/// Transitions:
/// disconnected -> connecting -> connected
/// -> disconnected (immediate failure)
/// connected -> disconnected -> reconnecting -> connecting -> ...
enum WsConnectionState {
disconnected,
connecting,
connected,
reconnecting,
}

View File

@@ -0,0 +1,6 @@
// Public import for the platform-split transport. Consumers do:
// import 'package:telemetry_monitor/transport/websocket_transport.dart';
// and the right implementation is selected at compile time.
export 'websocket_transport_io.dart'
if (dart.library.html) 'websocket_transport_web.dart';

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'connection_state.dart';
/// Common base for the platform-specific WebSocket transports.
///
/// Owns reconnection with exponential backoff. Subclasses implement only the
/// platform-specific channel construction.
abstract class WebSocketTransportBase {
WebSocketTransportBase({
this.initialReconnectDelay = const Duration(milliseconds: 500),
this.maxReconnectDelay = const Duration(seconds: 30),
this.backoffFactor = 2.0,
});
Duration initialReconnectDelay;
Duration maxReconnectDelay;
double backoffFactor;
final ValueNotifier<WsConnectionState> _state =
ValueNotifier(WsConnectionState.disconnected);
ValueListenable<WsConnectionState> get state => _state;
final StreamController<Uint8List> _frames = StreamController.broadcast();
Stream<Uint8List> get frames => _frames.stream;
String? _url;
String? get currentUrl => _url;
bool _shouldReconnect = false;
Duration _nextReconnectDelay = const Duration(milliseconds: 500);
Timer? _reconnectTimer;
/// Connect to [url]. If a connection already exists it is closed first.
Future<void> connect(String url) async {
await disconnect();
_url = url;
_shouldReconnect = true;
_nextReconnectDelay = initialReconnectDelay;
await _openOnce();
}
/// Close the connection and stop reconnecting.
Future<void> disconnect() async {
_shouldReconnect = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
await _closePlatform();
_state.value = WsConnectionState.disconnected;
}
/// Subclasses call this when bytes arrive.
@protected
void onBytes(Uint8List bytes) => _frames.add(bytes);
/// Subclasses call this when the underlying channel closes (with or without
/// error). Triggers reconnect if [_shouldReconnect] is true.
@protected
void onChannelClosed() {
if (!_shouldReconnect) {
_state.value = WsConnectionState.disconnected;
return;
}
_state.value = WsConnectionState.reconnecting;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_nextReconnectDelay, _openOnce);
final next = _nextReconnectDelay * backoffFactor;
_nextReconnectDelay = next > maxReconnectDelay ? maxReconnectDelay : next;
}
/// Subclass: open the platform-specific channel using [_url] and call
/// [onBytes]/[onChannelClosed] as appropriate.
@protected
Future<void> openPlatform(String url);
/// Subclass: close the platform-specific channel if open.
@protected
Future<void> _closePlatform();
Future<void> _openOnce() async {
if (_url == null) return;
_state.value = WsConnectionState.connecting;
try {
await openPlatform(_url!);
_state.value = WsConnectionState.connected;
_nextReconnectDelay = initialReconnectDelay;
} catch (_) {
onChannelClosed();
}
}
Future<void> dispose() async {
await disconnect();
await _frames.close();
_state.dispose();
}
}

View File

@@ -0,0 +1,51 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'websocket_transport_base.dart';
/// Native (Linux + Android) WebSocket transport using IOWebSocketChannel.
class WebSocketTransport extends WebSocketTransportBase {
WebSocketTransport({
super.initialReconnectDelay,
super.maxReconnectDelay,
super.backoffFactor,
});
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _sub;
@override
Future<void> openPlatform(String url) async {
_channel = IOWebSocketChannel.connect(
Uri.parse(url),
pingInterval: const Duration(seconds: 10),
);
// Awaiting `ready` surfaces connection failures as exceptions.
await _channel!.ready;
_sub = _channel!.stream.listen(
(dynamic message) {
if (message is Uint8List) {
onBytes(message);
} else if (message is List<int>) {
onBytes(Uint8List.fromList(message));
}
// String messages are ignored — server should send binary protobuf.
},
onError: (_) => onChannelClosed(),
onDone: onChannelClosed,
cancelOnError: true,
);
}
@override
Future<void> _closePlatform() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close(WebSocketStatus.normalClosure);
_channel = null;
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:web_socket_channel/html.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'websocket_transport_base.dart';
/// Web WebSocket transport using HtmlWebSocketChannel.
class WebSocketTransport extends WebSocketTransportBase {
WebSocketTransport({
super.initialReconnectDelay,
super.maxReconnectDelay,
super.backoffFactor,
});
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _sub;
@override
Future<void> openPlatform(String url) async {
_channel = HtmlWebSocketChannel.connect(Uri.parse(url));
await _channel!.ready;
_sub = _channel!.stream.listen(
(dynamic message) {
if (message is Uint8List) {
onBytes(message);
} else if (message is ByteBuffer) {
onBytes(message.asUint8List());
} else if (message is List<int>) {
onBytes(Uint8List.fromList(message));
}
},
onError: (_) => onChannelClosed(),
onDone: onChannelClosed,
cancelOnError: true,
);
}
@override
Future<void> _closePlatform() async {
await _sub?.cancel();
_sub = null;
await _channel?.sink.close(1000); // Normal closure.
_channel = null;
}
}

39
lib/ui/app_scope.dart Normal file
View File

@@ -0,0 +1,39 @@
import 'package:flutter/widgets.dart';
import '../config/settings.dart';
import '../export/csv_exporter.dart';
import '../layout/layout_controller.dart';
import '../session/session_controller.dart';
/// Inherited widget that exposes the app's controllers to descendants.
///
/// Plain InheritedWidget rather than Provider/Riverpod, per the design
/// decision to avoid a state-management dependency.
class AppScope extends InheritedWidget {
const AppScope({
super.key,
required this.session,
required this.layout,
required this.settings,
required this.exporter,
required super.child,
});
final SessionController session;
final LayoutController layout;
final Settings settings;
final CsvExporter exporter;
static AppScope of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope.of() called with no AppScope ancestor');
return scope!;
}
@override
bool updateShouldNotify(AppScope old) =>
session != old.session ||
layout != old.layout ||
settings != old.settings ||
exporter != old.exporter;
}

56
lib/ui/chart_grid.dart Normal file
View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'app_scope.dart';
import 'chart_widget.dart';
class ChartGrid extends StatelessWidget {
const ChartGrid({super.key});
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return ListenableBuilder(
listenable: scope.layout,
builder: (_, __) {
final grid = scope.layout.grid;
return GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: grid.cols,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 16 / 9,
),
itemCount: grid.cellCount,
itemBuilder: (_, i) {
final cellNumber = i + 1;
final ch = grid.channelForCell(cellNumber);
if (ch == null) return const _EmptyCell();
final cfg = scope.layout.configFor(ch);
if (!cfg.enabled) return const _EmptyCell();
return RepaintBoundary(child: ChartWidget(channel: ch));
},
);
},
);
}
}
class _EmptyCell extends StatelessWidget {
const _EmptyCell();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(6),
),
child: const Center(
child: Text('empty', style: TextStyle(color: Colors.grey)),
),
);
}
}

174
lib/ui/chart_painter.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import '../layout/chart_config.dart';
import '../layout/layout_controller.dart';
import '../session/decimator.dart';
import '../session/session_controller.dart';
/// Stateless per-frame painter. Reads everything fresh from the controllers
/// each `paint()` — no internal state.
class ChartPainter extends CustomPainter {
ChartPainter({
required this.channel,
required this.session,
required this.layout,
});
final int channel;
final SessionController session;
final LayoutController layout;
@override
void paint(Canvas canvas, Size size) {
final padLeft = 32.0, padRight = 4.0, padTop = 4.0, padBottom = 16.0;
final plotW = size.width - padLeft - padRight;
final plotH = size.height - padTop - padBottom;
if (plotW <= 0 || plotH <= 0) return;
// Background.
final bgPaint = Paint()..color = const Color(0xFFF7F7F4);
canvas.drawRect(
Rect.fromLTWH(padLeft, padTop, plotW, plotH),
bgPaint,
);
// Compute window.
final newest = session.packets.newest;
final oldestUs = session.packets.oldest?.timestampUs.toInt() ?? 0;
final newestUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
final win = session.viewState.currentWindow(
nowUs: newestUs,
oldestUs: oldestUs,
newestUs: newestUs,
);
// Decimate.
final cols = session.decimator.decimate(
channel: channel,
buffer: session.packets,
startUs: win.startUs,
endUs: win.endUs,
pixelWidth: plotW.round(),
);
final gaps = session.decimator.computeGaps(
channel: channel,
buffer: session.packets,
startUs: win.startUs,
endUs: win.endUs,
pixelWidth: plotW.round(),
);
// Y range.
final cfg = layout.configFor(channel);
var yMin = cfg.yMin, yMax = cfg.yMax;
if (cfg.yMode == YMode.auto) {
double lo = double.infinity, hi = double.negativeInfinity;
for (final c in cols) {
if (!c.hasData) continue;
if (c.min < lo) lo = c.min;
if (c.max > hi) hi = c.max;
}
if (lo.isFinite && hi.isFinite && hi > lo) {
yMin = lo;
yMax = hi;
}
}
final ySpan = (yMax - yMin).abs() < 1e-12 ? 1.0 : (yMax - yMin);
double xForCol(int col) => padLeft + col + 0.5;
double yForVal(double v) =>
padTop + plotH * (1 - (v - yMin) / ySpan);
// Hatched gap rectangles.
final hatchPaint = Paint()
..color = const Color(0x55993C1D)
..style = PaintingStyle.fill;
for (final g in gaps.hatchedRanges) {
final x0 = xForCol(g.startPx);
final x1 = xForCol(g.endPx + 1);
canvas.drawRect(
Rect.fromLTRB(x0, padTop, x1, padTop + plotH),
hatchPaint,
);
}
// Polyline.
final linePaint = Paint()
..color = const Color(0xFF185FA5)
..strokeWidth = 1.2
..style = PaintingStyle.stroke;
final path = Path();
bool started = false;
for (var i = 0; i < cols.length; i++) {
final c = cols[i];
if (!c.hasData) {
started = false;
continue;
}
final x = xForCol(i);
final yMinPx = yForVal(c.max); // Higher value = smaller y.
final yMaxPx = yForVal(c.min);
if (!started) {
path.moveTo(x, (yMinPx + yMaxPx) / 2);
started = true;
} else {
path.lineTo(x, (yMinPx + yMaxPx) / 2);
}
// Min/max vertical span to surface intra-column variation.
if ((yMaxPx - yMinPx).abs() > 0.5) {
canvas.drawLine(Offset(x, yMinPx), Offset(x, yMaxPx), linePaint);
}
}
canvas.drawPath(path, linePaint);
// Sub-pixel marker lines.
final markerPaint = Paint()
..color = const Color(0xAA993C1D)
..strokeWidth = 1;
for (final px in gaps.markerPixels) {
final x = xForCol(px);
canvas.drawLine(
Offset(x, padTop),
Offset(x, padTop + plotH),
markerPaint,
);
}
// Axes labels.
_drawText(canvas, '${yMax.toStringAsFixed(2)}',
Offset(padLeft - 4, padTop), align: TextAlign.right);
_drawText(canvas, '${yMin.toStringAsFixed(2)}',
Offset(padLeft - 4, padTop + plotH - 12), align: TextAlign.right);
_drawText(canvas, _fmtTime(win.startUs),
Offset(padLeft, size.height - 14));
_drawText(canvas, _fmtTime(win.endUs),
Offset(size.width - padRight, size.height - 14),
align: TextAlign.right);
}
void _drawText(Canvas c, String s, Offset where,
{TextAlign align = TextAlign.left}) {
final tp = TextPainter(
text: TextSpan(
text: s,
style: const TextStyle(fontSize: 9, color: Colors.grey),
),
textDirection: TextDirection.ltr,
textAlign: align,
)..layout();
final dx = align == TextAlign.right ? where.dx - tp.width : where.dx;
tp.paint(c, Offset(dx, where.dy));
}
String _fmtTime(int us) {
final d = DateTime.fromMicrosecondsSinceEpoch(us);
return '${d.hour.toString().padLeft(2, '0')}:'
'${d.minute.toString().padLeft(2, '0')}:'
'${d.second.toString().padLeft(2, '0')}.'
'${d.millisecond.toString().padLeft(3, '0')}';
}
@override
bool shouldRepaint(ChartPainter old) => true; // Per-frame repaint.
}

189
lib/ui/chart_widget.dart Normal file
View File

@@ -0,0 +1,189 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../layout/chart_config.dart';
import '../session/view_state.dart';
import 'app_scope.dart';
import 'chart_painter.dart';
/// One chart cell: header + painted plot area. Listens to frameTick for
/// repaints, and translates pointer events into view-state and y-zoom edits.
class ChartWidget extends StatefulWidget {
const ChartWidget({super.key, required this.channel});
final int channel;
@override
State<ChartWidget> createState() => _ChartWidgetState();
}
class _ChartWidgetState extends State<ChartWidget> {
Offset? _dragStart;
int? _dragStartUs;
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Container(
decoration: BoxDecoration(
border:
Border.all(color: Theme.of(context).colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(channel: widget.channel),
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
return Listener(
onPointerSignal: (ev) =>
_onPointerSignal(ev, constraints),
child: GestureDetector(
onDoubleTap: () => _resetY(),
onHorizontalDragStart: (d) => _dragStartHandler(d),
onHorizontalDragUpdate: (d) =>
_dragUpdate(d, constraints),
onHorizontalDragEnd: (_) => _dragStart = null,
child: ValueListenableBuilder<int>(
valueListenable: scope.session.frameTick,
builder: (_, __, ___) {
return CustomPaint(
painter: ChartPainter(
channel: widget.channel,
session: scope.session,
layout: scope.layout,
),
size: Size.infinite,
);
},
),
),
);
}),
),
],
),
);
}
void _onPointerSignal(PointerSignalEvent ev, BoxConstraints c) {
if (ev is! PointerScrollEvent) return;
final scope = AppScope.of(context);
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
final factor = ev.scrollDelta.dy > 0 ? 1.1 : 1 / 1.1;
if (ctrl) {
// Y-zoom for this chart only.
final cfg = scope.layout.configFor(widget.channel);
final localY = ev.localPosition.dy;
// Map local Y back to data value (top = yMax, bottom = yMin).
final h = c.maxHeight;
final v =
cfg.yMax - (localY / h) * (cfg.yMax - cfg.yMin);
scope.layout.zoomY(
channel: widget.channel,
pivotValue: v,
factor: factor,
);
} else {
// Shared X-zoom.
final newest = scope.session.packets.newest;
final oldestUs =
scope.session.packets.oldest?.timestampUs.toInt() ?? 0;
final newestUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
final win = scope.session.viewState.currentWindow(
nowUs: newestUs,
oldestUs: oldestUs,
newestUs: newestUs,
);
final localX = ev.localPosition.dx;
final w = c.maxWidth;
final pivotUs = win.startUs +
((localX / w) * (win.endUs - win.startUs)).round();
final maxWindow = Duration(
microseconds: (newestUs - oldestUs).clamp(
const Duration(milliseconds: 10).inMicroseconds,
1 << 62,
),
);
scope.session.viewState.zoomAt(
pivotUs: pivotUs,
factor: factor,
maxWindow: maxWindow,
oldestUs: oldestUs,
newestUs: newestUs,
);
}
}
void _dragStartHandler(DragStartDetails d) {
final scope = AppScope.of(context);
_dragStart = d.localPosition;
final newest = scope.session.packets.newest;
final newestUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
_dragStartUs = newestUs;
}
void _dragUpdate(DragUpdateDetails d, BoxConstraints c) {
final scope = AppScope.of(context);
if (_dragStart == null || _dragStartUs == null) return;
final dxPx = d.localPosition.dx - _dragStart!.dx;
final widthUs = scope.session.viewState.windowWidth.inMicroseconds;
final usPerPx = widthUs / c.maxWidth;
final shiftUs = (-dxPx * usPerPx).round();
final newest = scope.session.packets.newest;
final oldestUs =
scope.session.packets.oldest?.timestampUs.toInt() ?? 0;
final newestUs = newest?.timestampUs.toInt() ?? _dragStartUs!;
scope.session.viewState.panBy(
Duration(microseconds: shiftUs),
oldestUs: oldestUs,
newestUs: newestUs,
);
_dragStart = d.localPosition;
}
void _resetY() {
final scope = AppScope.of(context);
scope.layout.setYMode(widget.channel, YMode.auto);
}
}
class _Header extends StatelessWidget {
const _Header({required this.channel});
final int channel;
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return ListenableBuilder(
listenable: scope.layout,
builder: (_, __) {
final cfg = scope.layout.configFor(channel);
final modeTag = switch (cfg.yMode) {
YMode.auto => 'auto',
YMode.fixed => 'fixed',
YMode.userZoomed => 'user',
};
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Row(
children: [
Text('CH$channel · ${cfg.name}',
style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 12)),
const Spacer(),
Text(
'$modeTag · y: ${cfg.yMin.toStringAsFixed(2)}'
'${cfg.yMax.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
);
},
);
}
}

28
lib/ui/dashboard_tab.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'app_scope.dart';
import 'chart_grid.dart';
import 'mini_log_panel.dart';
class DashboardTab extends StatelessWidget {
const DashboardTab({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
const Expanded(flex: 4, child: ChartGrid()),
const SizedBox(height: 8),
Expanded(
flex: 1,
child: MiniLogPanel(
severities: AppScope.of(context).settings.miniLogSeverities,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class ClearConfirmDialog extends StatelessWidget {
const ClearConfirmDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Clear all data?'),
content: const Text(
'This will discard all packets and log entries currently in memory. '
'Cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style:
FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
child: const Text('Clear all'),
),
],
);
}
}

View File

@@ -0,0 +1,387 @@
import 'package:flutter/material.dart';
import '../../config/app_config.dart';
import '../../layout/chart_config.dart';
import '../../layout/layout_controller.dart';
import '../app_scope.dart';
class LayoutDialog extends StatefulWidget {
const LayoutDialog({super.key});
@override
State<LayoutDialog> createState() => _LayoutDialogState();
}
class _LayoutDialogState extends State<LayoutDialog> {
late LayoutController _draft;
static const _gridOptions = [
(2, 2), (2, 3), (3, 3), (3, 4), (4, 4),
];
@override
void initState() {
super.initState();
final live = context.findAncestorWidgetOfExactType<AppScope>()!.layout;
_draft = live.clone();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Layout configuration'),
content: SizedBox(
width: 620,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_gridSection(),
const SizedBox(height: 16),
_channelsSection(),
const SizedBox(height: 16),
_statusNamesSection(),
],
),
),
),
actions: [
TextButton(
onPressed: () {
setState(() {
_draft.grid.autoAssign(
_draft.charts
.where((c) => c.enabled)
.map((c) => c.channel)
.toList(),
);
});
},
child: const Text('Auto-assign'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => _apply(context),
child: const Text('Apply'),
),
],
);
}
Future<void> _apply(BuildContext context) async {
final scope = AppScope.of(context);
scope.layout.copyFrom(_draft);
await scope.layout.save();
if (context.mounted) Navigator.of(context).pop();
}
Widget _gridSection() {
final enabled =
_draft.charts.where((c) => c.enabled).length;
final empty = _draft.grid.cellCount -
_draft.grid.cellChannels.where((c) => c != null).length;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Grid size',
style: TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
DropdownButton<(int, int)>(
value: _gridOptions.firstWhere(
(g) => g.$1 == _draft.grid.rows && g.$2 == _draft.grid.cols,
orElse: () => _gridOptions.first,
),
items: _gridOptions
.map((g) => DropdownMenuItem(
value: g,
child: Text('${g.$1} × ${g.$2}'),
))
.toList(),
onChanged: (g) {
if (g == null) return;
setState(() => _draft.grid.resize(g.$1, g.$2));
},
),
const SizedBox(height: 4),
Text(
'${_draft.grid.cellCount} cells · '
'$enabled enabled · $empty empty',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
const SizedBox(width: 16),
Expanded(child: _gridPreview()),
],
);
}
Widget _gridPreview() {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _draft.grid.cols,
mainAxisSpacing: 6,
crossAxisSpacing: 6,
childAspectRatio: 16 / 9,
),
itemCount: _draft.grid.cellCount,
itemBuilder: (_, i) {
final cellNumber = i + 1;
final ch = _draft.grid.channelForCell(cellNumber);
if (ch == null) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade400,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$cellNumber',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: Colors.grey)),
const Text('empty',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
fontStyle: FontStyle.italic)),
],
),
),
);
}
final cfg = _draft.configFor(ch);
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$cellNumber',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 10,
color: Colors.grey)),
Text('CH$ch · ${cfg.name}',
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
],
),
),
);
},
),
);
}
Widget _channelsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text('Channels',
style: TextStyle(fontWeight: FontWeight.w500)),
),
...List.generate(AppConfig.channelCount, (i) => _channelRow(i + 1)),
],
);
}
Widget _channelRow(int channel) {
final cfg = _draft.configFor(channel);
final assignedCell = () {
for (var i = 0; i < _draft.grid.cellChannels.length; i++) {
if (_draft.grid.cellChannels[i] == channel) return i + 1;
}
return 0; // 0 = unassigned
}();
return Opacity(
opacity: cfg.enabled ? 1.0 : 0.55,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Checkbox(
value: cfg.enabled,
onChanged: (v) => setState(() {
cfg.enabled = v ?? false;
if (!cfg.enabled) {
for (var i = 0; i < _draft.grid.cellChannels.length; i++) {
if (_draft.grid.cellChannels[i] == channel) {
_draft.grid.cellChannels[i] = null;
}
}
}
}),
),
SizedBox(
width: 40,
child: Text('CH$channel',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Colors.grey)),
),
Expanded(
child: TextFormField(
initialValue: cfg.name,
enabled: cfg.enabled,
decoration: const InputDecoration(
isDense: true, contentPadding: EdgeInsets.all(6)),
onChanged: (v) => cfg.name = v,
),
),
const SizedBox(width: 6),
SizedBox(
width: 64,
child: DropdownButton<int>(
value: assignedCell,
isExpanded: true,
onChanged: cfg.enabled
? (v) => setState(() {
_draft.grid.assign(v ?? 0, v == 0 ? null : channel);
})
: null,
items: [
const DropdownMenuItem(value: 0, child: Text('')),
...List.generate(
_draft.grid.cellCount,
(i) => DropdownMenuItem(
value: i + 1,
child: Text('${i + 1}'),
),
),
],
),
),
const SizedBox(width: 6),
SizedBox(
width: 78,
child: DropdownButton<YMode>(
value:
cfg.yMode == YMode.userZoomed ? YMode.fixed : cfg.yMode,
isExpanded: true,
onChanged: cfg.enabled
? (m) => setState(() => cfg.yMode = m ?? YMode.auto)
: null,
items: const [
DropdownMenuItem(value: YMode.auto, child: Text('auto')),
DropdownMenuItem(value: YMode.fixed, child: Text('fixed')),
],
),
),
const SizedBox(width: 6),
SizedBox(
width: 70,
child: TextFormField(
initialValue: cfg.yMin.toString(),
enabled: cfg.enabled && cfg.yMode != YMode.auto,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
textAlign: TextAlign.right,
decoration: const InputDecoration(
isDense: true, contentPadding: EdgeInsets.all(6)),
onChanged: (v) =>
cfg.yMin = double.tryParse(v) ?? cfg.yMin,
),
),
const SizedBox(width: 4),
SizedBox(
width: 70,
child: TextFormField(
initialValue: cfg.yMax.toString(),
enabled: cfg.enabled && cfg.yMode != YMode.auto,
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
textAlign: TextAlign.right,
decoration: const InputDecoration(
isDense: true, contentPadding: EdgeInsets.all(6)),
onChanged: (v) =>
cfg.yMax = double.tryParse(v) ?? cfg.yMax,
),
),
],
),
),
);
}
Widget _statusNamesSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text('Status indicator names',
style: TextStyle(fontWeight: FontWeight.w500)),
),
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Names shown on the status bar pills for the 8 status fields.',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 4,
crossAxisSpacing: 10,
childAspectRatio: 6,
),
itemCount: 8,
itemBuilder: (_, i) => Row(children: [
SizedBox(
width: 56,
child: Text('status${i + 1}',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Colors.grey)),
),
Expanded(
child: TextFormField(
initialValue: _draft.statusNames[i],
decoration: const InputDecoration(
isDense: true, contentPadding: EdgeInsets.all(6)),
onChanged: (v) => _draft.statusNames[i] = v,
),
),
]),
),
],
);
}
}

View File

@@ -0,0 +1,280 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../config/settings.dart';
import '../../transport/connection_state.dart';
import '../app_scope.dart';
class SettingsDialog extends StatefulWidget {
const SettingsDialog({super.key});
@override
State<SettingsDialog> createState() => _SettingsDialogState();
}
class _SettingsDialogState extends State<SettingsDialog> {
late Settings _draft;
late TextEditingController _wsCtrl;
late TextEditingController _pktCtrl;
late TextEditingController _logCtrl;
late TextEditingController _batchCtrl;
late TextEditingController _initCtrl;
late TextEditingController _maxCtrl;
late TextEditingController _backoffCtrl;
late TextEditingController _lookbackCtrl;
@override
void initState() {
super.initState();
final live = context.findAncestorWidgetOfExactType<AppScope>()!.settings;
_draft = live.clone();
_wsCtrl = TextEditingController(text: _draft.wsUrl);
_pktCtrl = TextEditingController(text: _draft.packetBufferCapacity.toString());
_logCtrl = TextEditingController(text: _draft.logBufferCapacity.toString());
_batchCtrl = TextEditingController(
text: _draft.decoderBatchInterval.inMilliseconds.toString());
_initCtrl = TextEditingController(
text: _draft.reconnectInitialDelay.inMilliseconds.toString());
_maxCtrl = TextEditingController(
text: _draft.reconnectMaxDelay.inMilliseconds.toString());
_backoffCtrl =
TextEditingController(text: _draft.reconnectBackoffFactor.toString());
_lookbackCtrl =
TextEditingController(text: _draft.statusLookback.toString());
}
@override
void dispose() {
for (final c in [
_wsCtrl, _pktCtrl, _logCtrl, _batchCtrl,
_initCtrl, _maxCtrl, _backoffCtrl, _lookbackCtrl,
]) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return AlertDialog(
title: const Text('Settings'),
content: SizedBox(
width: 480,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_section('Connection'),
TextField(
controller: _wsCtrl,
decoration: const InputDecoration(labelText: 'WebSocket URL'),
style: const TextStyle(fontFamily: 'monospace'),
),
const SizedBox(height: 8),
ValueListenableBuilder<WsConnectionState>(
valueListenable: scope.session.connectionState,
builder: (_, state, __) {
final dotColor = switch (state) {
WsConnectionState.connected => Colors.green,
WsConnectionState.connecting ||
WsConnectionState.reconnecting => Colors.orange,
WsConnectionState.disconnected => Colors.grey,
};
final label = switch (state) {
WsConnectionState.connected => 'Connected to',
WsConnectionState.connecting => 'Connecting to',
WsConnectionState.reconnecting => 'Reconnecting to',
WsConnectionState.disconnected => 'Disconnected',
};
final url = scope.transport_url();
final urlChanged = _wsCtrl.text != url;
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(6),
),
child: Row(children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text('$label ', style: const TextStyle(fontSize: 11)),
Text(url,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
const Spacer(),
if (urlChanged)
const Text('URL changed — will reconnect on Apply',
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: Colors.grey)),
]),
);
},
),
_section('Buffers'),
_numberField('Packet buffer', _pktCtrl, hint: '≈ 10 min @ 1 kHz'),
_numberField('Log buffer', _logCtrl, hint: 'entries'),
_section('Decoder'),
_numberField('Batch interval', _batchCtrl, hint: 'ms (native)'),
if (kIsWeb)
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text(
'Web target decodes inline; this setting is ignored.',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
),
_section('Reconnect'),
_numberField('Initial delay', _initCtrl, hint: 'ms'),
_numberField('Max delay', _maxCtrl, hint: 'ms'),
_numberField('Backoff factor', _backoffCtrl, hint: '×'),
_section('Status indicators'),
_numberField('Status lookback', _lookbackCtrl,
hint: '≈ 1 s @ 1 kHz'),
_section('Dashboard mini log'),
..._severityCheckboxes(),
],
),
),
),
actions: [
TextButton(
onPressed: () {
_draft.restoreDefaults(isWeb: kIsWeb);
setState(() {
_wsCtrl.text = _draft.wsUrl;
_pktCtrl.text = _draft.packetBufferCapacity.toString();
_logCtrl.text = _draft.logBufferCapacity.toString();
_batchCtrl.text =
_draft.decoderBatchInterval.inMilliseconds.toString();
_initCtrl.text =
_draft.reconnectInitialDelay.inMilliseconds.toString();
_maxCtrl.text =
_draft.reconnectMaxDelay.inMilliseconds.toString();
_backoffCtrl.text = _draft.reconnectBackoffFactor.toString();
_lookbackCtrl.text = _draft.statusLookback.toString();
});
},
child: const Text('Restore defaults'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => _apply(context),
child: const Text('Apply'),
),
],
);
}
Future<void> _apply(BuildContext context) async {
_draft.wsUrl = _wsCtrl.text;
_draft.packetBufferCapacity = int.tryParse(_pktCtrl.text) ??
_draft.packetBufferCapacity;
_draft.logBufferCapacity =
int.tryParse(_logCtrl.text) ?? _draft.logBufferCapacity;
_draft.decoderBatchInterval = Duration(
milliseconds: int.tryParse(_batchCtrl.text) ??
_draft.decoderBatchInterval.inMilliseconds,
);
_draft.reconnectInitialDelay = Duration(
milliseconds: int.tryParse(_initCtrl.text) ??
_draft.reconnectInitialDelay.inMilliseconds,
);
_draft.reconnectMaxDelay = Duration(
milliseconds: int.tryParse(_maxCtrl.text) ??
_draft.reconnectMaxDelay.inMilliseconds,
);
_draft.reconnectBackoffFactor =
double.tryParse(_backoffCtrl.text) ?? _draft.reconnectBackoffFactor;
_draft.statusLookback =
int.tryParse(_lookbackCtrl.text) ?? _draft.statusLookback;
final scope = AppScope.of(context);
final urlChanged = scope.settings.wsUrl != _draft.wsUrl;
scope.settings.copyFrom(_draft);
await scope.settings.save();
// Resize buffers if capacity changed.
if (scope.session.packets.capacity != _draft.packetBufferCapacity) {
scope.session.packets.resize(_draft.packetBufferCapacity);
}
if (scope.session.logs.capacity != _draft.logBufferCapacity) {
scope.session.logs.resize(_draft.logBufferCapacity);
}
if (urlChanged) {
await scope.session.reconnect();
}
if (context.mounted) Navigator.of(context).pop();
}
Widget _section(String label) => Padding(
padding: const EdgeInsets.only(top: 16, bottom: 6),
child: Text(label,
style: const TextStyle(
fontWeight: FontWeight.w500, color: Colors.grey)),
);
Widget _numberField(String label, TextEditingController c,
{String? hint}) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
SizedBox(width: 130, child: Text(label)),
Expanded(
child: TextField(
controller: c,
keyboardType: TextInputType.number,
style: const TextStyle(fontFamily: 'monospace'),
),
),
if (hint != null) ...[
const SizedBox(width: 8),
SizedBox(
width: 100,
child:
Text(hint, style: const TextStyle(color: Colors.grey)),
),
],
],
),
);
}
List<Widget> _severityCheckboxes() {
const labels = {
1: 'DEBUG', 2: 'INFO', 3: 'WARN', 4: 'ERROR', 5: 'FATAL',
};
return labels.entries.map((e) {
return CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(e.value, style: const TextStyle(fontFamily: 'monospace')),
value: _draft.miniLogSeverities.contains(e.key),
onChanged: (v) => setState(() {
if (v == true) {
_draft.miniLogSeverities.add(e.key);
} else {
_draft.miniLogSeverities.remove(e.key);
}
}),
);
}).toList();
}
}
extension _ScopeTransport on AppScope {
// Helper: get the transport's actual current URL. Falls back to the
// settings value if transport is between connections.
String transport_url() => session.transport.currentUrl ?? settings.wsUrl;
}

123
lib/ui/full_log_tab.dart Normal file
View File

@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../proto/messages.pb.dart';
import 'app_scope.dart';
import 'log_list_view.dart';
class FullLogTab extends StatefulWidget {
const FullLogTab({super.key});
@override
State<FullLogTab> createState() => _FullLogTabState();
}
class _FullLogTabState extends State<FullLogTab> {
Set<int> _visible = {1, 2, 3, 4, 5};
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_FilterRow(
visible: _visible,
onToggle: (s) => setState(() {
if (_visible.contains(s)) {
_visible.remove(s);
} else {
_visible.add(s);
}
}),
onExport: () => _exportLog(context),
),
const Divider(height: 1),
Expanded(
child: LogListView(
session: scope.session,
filter: (e) => _visible.contains(
e.hasSeverity() ? e.severity.value : 0,
),
),
),
],
),
),
);
}
Future<void> _exportLog(BuildContext context) async {
final scope = AppScope.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
final result =
await scope.exporter.exportLog(buffer: scope.session.logs);
messenger.showSnackBar(
SnackBar(content: Text('Exported to ${result.path}')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({
required this.visible,
required this.onToggle,
required this.onExport,
});
final Set<int> visible;
final void Function(int) onToggle;
final VoidCallback onExport;
@override
Widget build(BuildContext context) {
Widget chip(int sev, String label, Color color) {
final on = visible.contains(sev);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: FilterChip(
label: Text(label, style: const TextStyle(fontSize: 11)),
selected: on,
onSelected: (_) => onToggle(sev),
selectedColor: color.withValues(alpha: 0.2),
checkmarkColor: color,
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
const Text('Filter:', style: TextStyle(fontSize: 11)),
const SizedBox(width: 6),
chip(1, 'DEBUG', Colors.grey),
chip(2, 'INFO', Colors.blueGrey),
chip(3, 'WARN', Colors.amber.shade800),
chip(4, 'ERROR', Colors.red.shade800),
chip(5, 'FATAL', Colors.red.shade900),
const Spacer(),
OutlinedButton.icon(
onPressed: onExport,
icon: const Icon(Icons.download, size: 16),
label: const Text('Export log'),
),
],
),
);
}
}

126
lib/ui/log_list_view.dart Normal file
View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import '../proto/messages.pb.dart';
import '../session/session_controller.dart';
typedef LogFilter = bool Function(LogPacket entry);
/// Reusable log viewer with auto-scroll-to-newest and disengage-on-manual-scroll.
class LogListView extends StatefulWidget {
const LogListView({
super.key,
required this.session,
required this.filter,
});
final SessionController session;
final LogFilter filter;
@override
State<LogListView> createState() => _LogListViewState();
}
class _LogListViewState extends State<LogListView> {
final ScrollController _ctrl = ScrollController();
bool _autoScroll = true;
@override
void initState() {
super.initState();
_ctrl.addListener(_onScroll);
widget.session.logTick.addListener(_onNewLog);
}
@override
void dispose() {
widget.session.logTick.removeListener(_onNewLog);
_ctrl.dispose();
super.dispose();
}
void _onScroll() {
if (!_ctrl.hasClients) return;
final atBottom = _ctrl.offset >=
_ctrl.position.maxScrollExtent - 4;
if (_autoScroll != atBottom) {
setState(() => _autoScroll = atBottom);
}
}
void _onNewLog() {
if (!_autoScroll || !_ctrl.hasClients) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_ctrl.hasClients) return;
_ctrl.jumpTo(_ctrl.position.maxScrollExtent);
});
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: widget.session.logTick,
builder: (_, __, ___) {
final entries = widget.session.logs
.iterate()
.where(widget.filter)
.toList(growable: false);
return ListView.builder(
controller: _ctrl,
itemCount: entries.length,
itemBuilder: (_, i) => _LogRow(entry: entries[i]),
);
},
);
}
}
class _LogRow extends StatelessWidget {
const _LogRow({required this.entry});
final LogPacket entry;
@override
Widget build(BuildContext context) {
final ts = entry.hasTimestampUs()
? _fmtTime(entry.timestampUs.toInt())
: '';
final sev = entry.hasSeverity() ? entry.severity.name : '';
final err = entry.hasErrorNumber()
? '[${entry.errorNumber.toString().padLeft(4, '0')}]'
: '';
final desc = entry.hasDescription() ? entry.description : '';
final sevColor = switch (sev) {
'WARN' => Colors.amber.shade800,
'ERROR' || 'FATAL' => Colors.red.shade800,
_ => Colors.grey.shade700,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1),
child: DefaultTextStyle(
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ts, style: const TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
SizedBox(
width: 50,
child: Text(sev, style: TextStyle(color: sevColor)),
),
const SizedBox(width: 6),
Text(err, style: const TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
Expanded(child: Text(desc, overflow: TextOverflow.ellipsis)),
],
),
),
);
}
String _fmtTime(int us) {
final d = DateTime.fromMicrosecondsSinceEpoch(us);
return '${d.hour.toString().padLeft(2, '0')}:'
'${d.minute.toString().padLeft(2, '0')}:'
'${d.second.toString().padLeft(2, '0')}.'
'${d.millisecond.toString().padLeft(3, '0')}';
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../proto/messages.pb.dart';
import 'app_scope.dart';
import 'log_list_view.dart';
/// Compact log panel on the Dashboard tab. Filters by severity using the
/// set configured in Settings (default ERROR + FATAL).
class MiniLogPanel extends StatelessWidget {
const MiniLogPanel({super.key, required this.severities});
final Set<int> severities;
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: const [
Text('Errors & fatals',
style: TextStyle(
fontWeight: FontWeight.w500, fontSize: 12)),
SizedBox(width: 6),
Text('· filtered view',
style: TextStyle(fontSize: 11, color: Colors.grey)),
],
),
),
const Divider(height: 1),
Expanded(
child: LogListView(
session: scope.session,
filter: (e) => severities.contains(
e.hasSeverity() ? e.severity.value : 0,
),
),
),
],
),
);
}
}

166
lib/ui/status_bar.dart Normal file
View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../session/status_snapshot.dart';
import '../session/view_state.dart';
import '../transport/connection_state.dart';
import 'app_scope.dart';
class StatusBar extends StatelessWidget {
const StatusBar({super.key});
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Material(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ValueListenableBuilder<StatusSnapshot>(
valueListenable: scope.session.statusSnapshot,
builder: (_, snap, __) {
return Wrap(
spacing: 14,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_ConnectionPill(snap.connection, scope.settings.wsUrl),
_Sep(),
_PpsPill(snap.pps),
_Sep(),
_PausePill(scope.session.pauseSource),
_Sep(),
const Text('Status:',
style: TextStyle(fontSize: 11, color: Colors.grey)),
...List.generate(8, (i) {
return _StatusPill(
name: scope.layout.statusNames[i],
value: snap.statusValues[i],
);
}),
],
);
},
),
),
);
}
}
class _Sep extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Container(width: 1, height: 14, color: Colors.grey.shade400);
}
class _ConnectionPill extends StatelessWidget {
const _ConnectionPill(this.state, this.url);
final WsConnectionState state;
final String url;
@override
Widget build(BuildContext context) {
final (color, label) = switch (state) {
WsConnectionState.connected => (Colors.green, 'WS connected'),
WsConnectionState.connecting => (Colors.orange, 'Connecting'),
WsConnectionState.reconnecting => (Colors.orange, 'Reconnecting'),
WsConnectionState.disconnected => (Colors.grey, 'Disconnected'),
};
return Row(mainAxisSize: MainAxisSize.min, children: [
_Dot(color),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 11)),
const SizedBox(width: 6),
Text(url,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11, color: Colors.grey)),
]);
}
}
class _PpsPill extends StatelessWidget {
const _PpsPill(this.pps);
final double pps;
@override
Widget build(BuildContext context) {
return Row(mainAxisSize: MainAxisSize.min, children: [
const Text('PPS ',
style: TextStyle(fontSize: 11, color: Colors.grey)),
Text(pps.round().toString(),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
fontWeight: FontWeight.w500)),
]);
}
}
class _PausePill extends StatelessWidget {
const _PausePill(this.source);
final PauseSource source;
@override
Widget build(BuildContext context) {
if (source == PauseSource.none) {
return const SizedBox.shrink();
}
final label = switch (source) {
PauseSource.user => 'Paused (user)',
PauseSource.proto => 'Paused (proto)',
PauseSource.both => 'Paused (user + proto)',
PauseSource.none => '',
};
return Row(mainAxisSize: MainAxisSize.min, children: [
_Dot(Colors.orange),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 11)),
]);
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.name, required this.value});
final String name;
final int? value;
@override
Widget build(BuildContext context) {
final (bg, fg, dot, text) = switch (value) {
null => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey,
'$name · ?'),
0 => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey,
'$name · 0'),
1 => (Colors.red.shade100, Colors.red.shade900, Colors.red,
'$name · 1'),
2 => (Colors.amber.shade100, Colors.amber.shade900, Colors.amber,
'$name · 2'),
3 => (Colors.green.shade100, Colors.green.shade900, Colors.green,
'$name · 3'),
_ => (Colors.grey.shade200, Colors.grey.shade700, Colors.grey,
'$name · $value'),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
_Dot(dot, size: 7),
const SizedBox(width: 5),
Text(text, style: TextStyle(fontSize: 10, color: fg)),
]),
);
}
}
class _Dot extends StatelessWidget {
const _Dot(this.color, {this.size = 8});
final Color color;
final double size;
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
}

70
lib/ui/tab_scaffold.dart Normal file
View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'app_scope.dart';
import 'dashboard_tab.dart';
import 'full_log_tab.dart';
import 'status_bar.dart';
import 'toolbar.dart';
/// Top-level scaffold: toolbar at top, two tabs (Dashboard / Full log),
/// status bar at bottom. Both tabs share the same controllers; switching
/// is purely a view change.
class TabScaffold extends StatefulWidget {
const TabScaffold({super.key});
@override
State<TabScaffold> createState() => _TabScaffoldState();
}
class _TabScaffoldState extends State<TabScaffold>
with SingleTickerProviderStateMixin {
late final TabController _tabs = TabController(length: 2, vsync: this);
@override
void dispose() {
_tabs.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Scaffold(
body: SafeArea(
child: Column(
children: [
const Toolbar(),
Material(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: TabBar(
controller: _tabs,
tabs: [
const Tab(text: 'Dashboard'),
Tab(
child: ValueListenableBuilder<int>(
valueListenable: scope.session.logTick,
builder: (_, __, ___) => Text(
'Full log (${scope.session.logs.length})',
),
),
),
],
),
),
Expanded(
child: TabBarView(
controller: _tabs,
physics: const NeverScrollableScrollPhysics(),
children: const [
DashboardTab(),
FullLogTab(),
],
),
),
const StatusBar(),
],
),
),
);
}
}

172
lib/ui/toolbar.dart Normal file
View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import '../session/view_state.dart';
import 'app_scope.dart';
import 'dialogs/clear_confirm_dialog.dart';
import 'dialogs/layout_dialog.dart';
import 'dialogs/settings_dialog.dart';
class Toolbar extends StatelessWidget {
const Toolbar({super.key});
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return Material(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
const Text(
'Telemetry Monitor',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(width: 12),
const _Sep(),
ListenableBuilder(
listenable: scope.session.viewState,
builder: (_, __) => OutlinedButton.icon(
onPressed: scope.session.viewState.togglePause,
icon: Icon(scope.session.viewState.userPaused
? Icons.play_arrow
: Icons.pause),
label: Text(
scope.session.viewState.userPaused ? 'Resume' : 'Pause'),
),
),
const SizedBox(width: 6),
ListenableBuilder(
listenable: scope.session.viewState,
builder: (_, __) => OutlinedButton.icon(
onPressed: scope.session.viewState.anchorMode ==
ViewAnchorMode.followLive
? null
: scope.session.viewState.goLive,
icon: const Icon(Icons.fast_forward),
label: const Text('Go live'),
),
),
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: () => scope.session.viewState.resetView(),
icon: const Icon(Icons.fit_screen),
label: const Text('Reset view'),
),
const _Sep(),
OutlinedButton.icon(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const LayoutDialog(),
),
icon: const Icon(Icons.grid_view),
label: const Text('Layout'),
),
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const SettingsDialog(),
),
icon: const Icon(Icons.settings),
label: const Text('Settings'),
),
const _Sep(),
OutlinedButton.icon(
onPressed: () => _exportData(context),
icon: const Icon(Icons.download),
label: const Text('Export data'),
),
const SizedBox(width: 6),
OutlinedButton.icon(
onPressed: () => _confirmClear(context),
icon: const Icon(Icons.delete_outline),
label: const Text('Clear all'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
),
const Spacer(),
const _ZoomReadout(),
],
),
),
);
}
Future<void> _exportData(BuildContext context) async {
final scope = AppScope.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
final result = await scope.exporter.exportData(
buffer: scope.session.packets,
layout: scope.layout,
);
messenger.showSnackBar(
SnackBar(content: Text('Exported to ${result.path}')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}
Future<void> _confirmClear(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => const ClearConfirmDialog(),
);
if (ok == true && context.mounted) {
AppScope.of(context).session.clearAll();
}
}
}
class _Sep extends StatelessWidget {
const _Sep();
@override
Widget build(BuildContext context) => Container(
width: 1,
height: 22,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
class _ZoomReadout extends StatelessWidget {
const _ZoomReadout();
@override
Widget build(BuildContext context) {
final scope = AppScope.of(context);
return ValueListenableBuilder<int>(
valueListenable: scope.session.frameTick,
builder: (_, __, ___) {
final view = scope.session.viewState;
final newest = scope.session.packets.newest;
final nowUs = newest?.timestampUs.toInt() ??
DateTime.now().microsecondsSinceEpoch;
final win = view.currentWindow(
nowUs: nowUs,
oldestUs: scope.session.packets.oldest?.timestampUs.toInt() ?? nowUs,
newestUs: nowUs,
);
final widthMs = view.windowWidth.inMilliseconds;
final centerUs = (win.startUs + win.endUs) ~/ 2;
final dt = DateTime.fromMicrosecondsSinceEpoch(centerUs);
final widthStr = widthMs >= 1000
? '${(widthMs / 1000).toStringAsFixed(2)} s'
: '$widthMs ms';
final hh = dt.hour.toString().padLeft(2, '0');
final mm = dt.minute.toString().padLeft(2, '0');
final ss = dt.second.toString().padLeft(2, '0');
final ms = dt.millisecond.toString().padLeft(3, '0');
return Text(
'Window: $widthStr · Center: $hh:$mm:$ss.$ms',
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12, color: Colors.grey),
);
},
);
}
}