First commit
This commit is contained in:
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