First commit
This commit is contained in:
41
lib/app.dart
Normal file
41
lib/app.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
lib/config/app_config.dart
Normal file
72
lib/config/app_config.dart
Normal 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
129
lib/config/settings.dart
Normal 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
2
lib/decoder/decoder.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
// Public import for the platform-split decoder.
|
||||
export 'decoder_isolate.dart' if (dart.library.html) 'decoder_inline.dart';
|
||||
19
lib/decoder/decoder_base.dart
Normal file
19
lib/decoder/decoder_base.dart
Normal 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();
|
||||
}
|
||||
37
lib/decoder/decoder_inline.dart
Normal file
37
lib/decoder/decoder_inline.dart
Normal 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)});
|
||||
}
|
||||
118
lib/decoder/decoder_isolate.dart
Normal file
118
lib/decoder/decoder_isolate.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
2
lib/export/csv_exporter.dart
Normal file
2
lib/export/csv_exporter.dart
Normal 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';
|
||||
126
lib/export/csv_exporter_base.dart
Normal file
126
lib/export/csv_exporter_base.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
86
lib/export/csv_exporter_io.dart
Normal file
86
lib/export/csv_exporter_io.dart
Normal 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);
|
||||
}
|
||||
111
lib/export/csv_exporter_web.dart
Normal file
111
lib/export/csv_exporter_web.dart
Normal 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);
|
||||
}
|
||||
10
lib/layout/app_config_safe.dart
Normal file
10
lib/layout/app_config_safe.dart
Normal 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;
|
||||
}
|
||||
60
lib/layout/chart_config.dart
Normal file
60
lib/layout/chart_config.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
77
lib/layout/grid_config.dart
Normal file
77
lib/layout/grid_config.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
202
lib/layout/layout_controller.dart
Normal file
202
lib/layout/layout_controller.dart
Normal 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
45
lib/main.dart
Normal 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
227
lib/session/decimator.dart
Normal 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);
|
||||
}
|
||||
52
lib/session/log_buffer.dart
Normal file
52
lib/session/log_buffer.dart
Normal 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]!;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
lib/session/packet_buffer.dart
Normal file
117
lib/session/packet_buffer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
lib/session/pps_counter.dart
Normal file
34
lib/session/pps_counter.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
lib/session/session_controller.dart
Normal file
175
lib/session/session_controller.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
lib/session/status_snapshot.dart
Normal file
32
lib/session/status_snapshot.dart
Normal 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
130
lib/session/view_state.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
12
lib/transport/connection_state.dart
Normal file
12
lib/transport/connection_state.dart
Normal 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,
|
||||
}
|
||||
6
lib/transport/websocket_transport.dart
Normal file
6
lib/transport/websocket_transport.dart
Normal 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';
|
||||
100
lib/transport/websocket_transport_base.dart
Normal file
100
lib/transport/websocket_transport_base.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
51
lib/transport/websocket_transport_io.dart
Normal file
51
lib/transport/websocket_transport_io.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
47
lib/transport/websocket_transport_web.dart
Normal file
47
lib/transport/websocket_transport_web.dart
Normal 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
39
lib/ui/app_scope.dart
Normal 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
56
lib/ui/chart_grid.dart
Normal 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
174
lib/ui/chart_painter.dart
Normal 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
189
lib/ui/chart_widget.dart
Normal 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
28
lib/ui/dashboard_tab.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/ui/dialogs/clear_confirm_dialog.dart
Normal file
28
lib/ui/dialogs/clear_confirm_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
387
lib/ui/dialogs/layout_dialog.dart
Normal file
387
lib/ui/dialogs/layout_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
280
lib/ui/dialogs/settings_dialog.dart
Normal file
280
lib/ui/dialogs/settings_dialog.dart
Normal 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
123
lib/ui/full_log_tab.dart
Normal 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
126
lib/ui/log_list_view.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
53
lib/ui/mini_log_panel.dart
Normal file
53
lib/ui/mini_log_panel.dart
Normal 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
166
lib/ui/status_bar.dart
Normal 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
70
lib/ui/tab_scaffold.dart
Normal 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
172
lib/ui/toolbar.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user