Files
meshcore-open/lib/screens/companion_radio_stats_screen.dart
2026-06-13 00:39:13 -07:00

335 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/theme/mesh_theme.dart';
import 'package:meshcore_open/widgets/mesh_ui.dart';
import 'package:provider/provider.dart';
class CompanionRadioStatsScreen extends StatefulWidget {
const CompanionRadioStatsScreen({super.key});
@override
State<CompanionRadioStatsScreen> createState() =>
_CompanionRadioStatsScreenState();
}
class _CompanionRadioStatsScreenState extends State<CompanionRadioStatsScreen> {
final List<double> _noiseHistory = [];
static const int _maxSamples = 120;
MeshCoreConnector? _connector;
DateTime? _lastChartSampleAt;
@override
void initState() {
super.initState();
final c = context.read<MeshCoreConnector>();
_connector = c;
c.acquireRadioStatsPolling();
c.setPollingInterval(1);
c.radioStatsNotifier.addListener(_onStatsUpdate);
}
void _onStatsUpdate() {
final s = _connector?.radioStatsNotifier.value;
if (s == null || !mounted) return;
if (_lastChartSampleAt == s.receivedAt) return;
_lastChartSampleAt = s.receivedAt;
setState(() {
_noiseHistory.add(s.noiseFloorDbm.toDouble());
while (_noiseHistory.length > _maxSamples) {
_noiseHistory.removeAt(0);
}
});
}
@override
void dispose() {
_connector?.radioStatsNotifier.removeListener(_onStatsUpdate);
_connector?.releaseRadioStatsPolling();
_connector?.setPollingInterval(30);
super.dispose();
}
Widget _tile(String text, IconData icon, Color color) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: MeshTheme.mono(fontSize: 13, color: scheme.onSurface),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.radioStats_screenTitle),
centerTitle: true,
),
body: Selector<MeshCoreConnector, ({bool connected, bool supported})>(
selector: (_, c) => (
connected: c.isConnected,
supported: c.supportsCompanionRadioStats,
),
builder: (context, state, _) {
if (!state.connected) {
return Center(child: Text(l10n.radioStats_notConnected));
}
if (!state.supported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
l10n.radioStats_firmwareTooOld,
textAlign: TextAlign.center,
),
),
);
}
final connector = context.read<MeshCoreConnector>();
final scheme = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return ValueListenableBuilder<CompanionRadioStats?>(
valueListenable: connector.radioStatsNotifier,
builder: (context, stats, _) {
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (stats != null) ...[
const SectionHeader(
'Signal',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
Icons.noise_aware,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastRssi(stats.lastRssiDbm),
Icons.wifi_tethering,
scheme.onSurfaceVariant,
),
const Divider(height: 1),
_tile(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
),
Icons.signal_cellular_alt,
MeshTheme.snrColor(stats.lastSnrDb, blocked: false),
),
],
),
),
const SectionHeader(
'Airtime',
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
),
MeshCard(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
padding: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_tile(
l10n.radioStats_txAir(stats.txAirSecs),
Icons.upload,
MeshPalette.blue,
),
const Divider(height: 1),
_tile(
l10n.radioStats_rxAir(stats.rxAirSecs),
Icons.download,
MeshPalette.blue,
),
],
),
),
] else ...[
const SizedBox(height: 80),
Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 8),
Center(
child: Text(
l10n.radioStats_waiting,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
],
SectionHeader(
l10n.radioStats_chartCaption,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
),
),
),
const SizedBox(height: 8),
],
);
},
);
},
),
);
}
}
class _NoiseChartPainter extends CustomPainter {
final List<double> samples;
final ColorScheme colorScheme;
final TextTheme textTheme;
_NoiseChartPainter({
required this.samples,
required this.colorScheme,
required this.textTheme,
});
@override
void paint(Canvas canvas, Size size) {
final bg = Paint()..color = colorScheme.surfaceContainerHighest;
final border = Paint()
..color = colorScheme.outlineVariant
..style = PaintingStyle.stroke
..strokeWidth = 1;
final grid = Paint()
..color = colorScheme.outlineVariant.withValues(alpha: 0.5)
..strokeWidth = 1;
final line = Paint()
..color = colorScheme.primary
..strokeWidth = 2
..style = PaintingStyle.stroke;
final rect = Rect.fromLTWH(0, 0, size.width, size.height);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
bg,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
border,
);
const padL = 40.0;
const padR = 8.0;
const padT = 8.0;
const padB = 24.0;
final chart = Rect.fromLTRB(
padL,
padT,
size.width - padR,
size.height - padB,
);
for (var i = 0; i <= 4; i++) {
final y = chart.top + (chart.height * i / 4);
canvas.drawLine(Offset(chart.left, y), Offset(chart.right, y), grid);
}
if (samples.length < 2) {
final tp = TextPainter(
text: TextSpan(
text: '',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(
canvas,
Offset(chart.left + 4, chart.top + chart.height / 2 - tp.height / 2),
);
return;
}
double minV = samples.reduce((a, b) => a < b ? a : b);
double maxV = samples.reduce((a, b) => a > b ? a : b);
if ((maxV - minV).abs() < 1) {
minV -= 2;
maxV += 2;
}
final span = maxV - minV;
for (var i = 0; i <= 4; i++) {
final v = maxV - span * i / 4;
final tp = _yAxisLabel(v);
final y = chart.top + (chart.height * i / 4) - tp.height / 2;
tp.paint(canvas, Offset(4, y));
}
final path = Path();
for (var i = 0; i < samples.length; i++) {
final x = chart.left + (chart.width * i / (samples.length - 1));
final t = (samples[i] - minV) / span;
final y = chart.bottom - t * chart.height;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, line);
}
@override
bool shouldRepaint(covariant _NoiseChartPainter oldDelegate) {
return oldDelegate.samples.length != samples.length ||
oldDelegate.colorScheme != colorScheme;
}
TextPainter _yAxisLabel(double v) {
final tp = TextPainter(
text: TextSpan(
text: v.round().toString(),
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
textDirection: TextDirection.ltr,
)..layout();
return tp;
}
}