Add companion radio stats, adaptive backoff, path hash width, and UI improvements

- Companion radio stats: poll and display noise floor, RSSI, SNR, airtime
  with dedicated ValueNotifier and ref-counted polling
- Adaptive RF-aware TX backoff based on radio conditions instead of fixed 5s
- Variable-width path hash support (1-3 bytes per hop)
- Air activity dot indicator in app bar with tap to open stats screen
- Jump to oldest unread setting for chat screens
- 1s send cooldown on DM and channel messages
- Link style: theme-aware orange, added EmailLinkifier
- New languages: Hungarian, Japanese, Korean
- Remove dead DeviceScreen and BatteryIndicatorChip
- Remove wakelock_plus dependency
- TX power fields now read as signed int8
This commit is contained in:
zjs81
2026-03-23 19:26:05 -07:00
parent e7e2bb91b8
commit 834850fb51
41 changed files with 17987 additions and 362 deletions
+1 -3
View File
@@ -294,9 +294,7 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: const Icon(Icons.vertical_align_top),
title: Text(context.l10n.appSettings_jumpToOldestUnread),
subtitle: Text(
context.l10n.appSettings_jumpToOldestUnreadSubtitle,
),
subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle),
value: settingsService.settings.jumpToOldestUnread,
onChanged: settingsService.setJumpToOldestUnread,
),
+3 -3
View File
@@ -1119,9 +1119,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final now = DateTime.now();
if (_lastChannelSendAt != null &&
now.difference(_lastChannelSendAt!) < const Duration(seconds: 1)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_sendCooldown)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
return;
}
_lastChannelSendAt = now;
+3 -2
View File
@@ -64,8 +64,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
pathHashByteWidth:
context.read<MeshCoreConnector>().pathHashByteWidth,
pathHashByteWidth: context
.read<MeshCoreConnector>()
.pathHashByteWidth,
),
),
),
+1 -4
View File
@@ -127,10 +127,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack,
child: Scaffold(
appBar: AppBar(
title: AppBarTitle(
context.l10n.channels_title,
indicators: false,
),
title: AppBarTitle(context.l10n.channels_title, indicators: false),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
+3 -3
View File
@@ -613,9 +613,9 @@ class _ChatScreenState extends State<ChatScreen> {
final now = DateTime.now();
if (_lastTextSendAt != null &&
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_sendCooldown)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_sendCooldown)));
return;
}
_lastTextSendAt = now;
@@ -0,0 +1,250 @@
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: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.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();
super.dispose();
}
@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.all(16),
children: [
if (stats != null) ...[
Text(
l10n.radioStats_noiseFloor(stats.noiseFloorDbm),
style: tt.titleMedium,
),
const SizedBox(height: 4),
Text(l10n.radioStats_lastRssi(stats.lastRssiDbm)),
Text(
l10n.radioStats_lastSnr(
stats.lastSnrDb.toStringAsFixed(1),
),
),
Text(l10n.radioStats_txAir(stats.txAirSecs)),
Text(l10n.radioStats_rxAir(stats.rxAirSecs)),
const SizedBox(height: 16),
] else
Text(l10n.radioStats_waiting),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: CustomPaint(
painter: _NoiseChartPainter(
samples: List<double>.from(_noiseHistory),
colorScheme: scheme,
textTheme: tt,
),
child: const SizedBox.expand(),
),
),
const SizedBox(height: 8),
Text(
l10n.radioStats_chartCaption,
style: tt.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
);
},
);
},
),
);
}
}
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 <= 2; i++) {
final v = maxV - span * i / 2;
final tp = _yAxisLabel(v);
final y = chart.top + (chart.height * i / 2) - 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;
}
}
+9 -6
View File
@@ -1244,8 +1244,9 @@ class _ContactsScreenState extends State<ContactsScreen>
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
@@ -1277,8 +1278,9 @@ class _ContactsScreenState extends State<ContactsScreen>
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
@@ -1324,8 +1326,9 @@ class _ContactsScreenState extends State<ContactsScreen>
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
final hw =
context.read<MeshCoreConnector>().pathHashByteWidth;
final hw = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
-267
View File
@@ -1,267 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/battery_indicator_chip.dart';
import '../widgets/radio_stats_entry.dart';
import 'channels_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
import 'settings_screen.dart';
/// Main hub screen after connecting to a MeshCore device
class DeviceScreen extends StatefulWidget {
const DeviceScreen({super.key});
@override
State<DeviceScreen> createState() => _DeviceScreenState();
}
class _DeviceScreenState extends State<DeviceScreen>
with DisconnectNavigationMixin {
bool _showBatteryVoltage = false;
int _quickIndex = 0;
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
// Auto-navigate back to scanner if disconnected
if (!checkConnectionAndNavigate(connector)) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return PopScope(
canPop: false,
child: Scaffold(
appBar: AppBar(
leadingWidth: 128,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
BatteryIndicatorChip(
connector: connector,
showVoltage: _showBatteryVoltage,
onPressed: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
),
const RadioStatsIconButton(),
],
),
titleSpacing: 16,
centerTitle: false,
title: _buildAppBarTitle(connector, theme),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: context.l10n.common_disconnect,
onPressed: () => _disconnect(context, connector),
),
IconButton(
icon: const Icon(Icons.tune),
tooltip: context.l10n.common_settings,
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
_buildConnectionCard(connector, context),
const SizedBox(height: 16),
_buildSectionLabel(theme, context.l10n.device_quickSwitch),
const SizedBox(height: 12),
_buildQuickSwitchBar(context),
],
),
),
),
);
},
);
}
Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.device_meshcore,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: colorScheme.onSurfaceVariant,
),
),
Text(
connector.deviceDisplayName,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
Widget _buildSectionLabel(ThemeData theme, String text) {
return Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: theme.colorScheme.onSurfaceVariant,
),
);
}
Widget _buildConnectionCard(
MeshCoreConnector connector,
BuildContext context,
) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 24,
backgroundColor: colorScheme.primaryContainer,
child: Icon(
Icons.wifi_tethering_rounded,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.deviceDisplayName,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
connector.deviceIdLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Chip(
avatar: Icon(
Icons.check_circle,
size: 18,
color: colorScheme.onSecondaryContainer,
),
label: Text(context.l10n.common_connected),
backgroundColor: colorScheme.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
visualDensity: VisualDensity.compact,
),
BatteryIndicatorChip(
connector: connector,
showVoltage: _showBatteryVoltage,
onPressed: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
),
],
),
],
),
),
);
}
Widget _buildQuickSwitchBar(BuildContext context) {
return QuickSwitchBar(
selectedIndex: _quickIndex,
onDestinationSelected: (index) {
_openQuickDestination(index, context);
},
);
}
void _openQuickDestination(int index, BuildContext context) {
if (_quickIndex != index) {
setState(() {
_quickIndex = index;
});
}
switch (index) {
case 0:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
);
break;
case 1:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
);
break;
case 2:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
);
break;
}
}
Future<void> _disconnect(
BuildContext context,
MeshCoreConnector connector,
) async {
await showDisconnectDialog(context, connector);
}
}
+3 -2
View File
@@ -2191,8 +2191,9 @@ class _MapScreenState extends State<MapScreen> {
if (_pathTrace.isNotEmpty)
IconButton(
onPressed: () {
final hashW =
context.read<MeshCoreConnector>().pathHashByteWidth;
final hashW = context
.read<MeshCoreConnector>()
.pathHashByteWidth;
Navigator.push(
context,
MaterialPageRoute(
+2 -2
View File
@@ -275,8 +275,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Text(l10n.radioStats_settingsTile),
subtitle: Text(l10n.radioStats_settingsSubtitle),
trailing: const Icon(Icons.chevron_right),
enabled: connector.isConnected &&
connector.supportsCompanionRadioStats,
enabled:
connector.isConnected && connector.supportsCompanionRadioStats,
onTap: () => pushCompanionRadioStatsScreen(context),
),
const Divider(height: 1),