First commit

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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