Merge branch 'main' into ez_location-snr

This commit is contained in:
ericz
2026-03-28 17:01:38 +01:00
112 changed files with 32071 additions and 2459 deletions
+7 -2
View File
@@ -3,6 +3,7 @@ import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/widgets/battery_indicator.dart';
import 'package:provider/provider.dart';
import 'radio_stats_entry.dart';
import 'snr_indicator.dart';
class AppBarTitle extends StatelessWidget {
@@ -10,12 +11,14 @@ class AppBarTitle extends StatelessWidget {
final Widget? leading;
final Widget? trailing;
final bool indicators;
final bool showBatteryIndicator;
final bool subtitle;
const AppBarTitle(
this.title, {
this.leading,
this.trailing,
this.indicators = true,
this.showBatteryIndicator = true,
this.subtitle = true,
super.key,
});
@@ -33,7 +36,7 @@ class AppBarTitle extends StatelessWidget {
final compact = availableWidth < 170;
final showSubtitle =
!compact && connector.isConnected && selfName != null && subtitle;
final showBattery = availableWidth >= 60;
final showBattery = showBatteryIndicator && availableWidth >= 60;
final showSnr = availableWidth >= 110;
final showIndicators = (showBattery || showSnr) && indicators;
@@ -60,11 +63,13 @@ class AppBarTitle extends StatelessWidget {
if (showIndicators) const SizedBox(width: 6),
if (showIndicators)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (showBattery) BatteryIndicator(connector: connector),
if (showSnr) SNRIndicator(connector: connector),
if (connector.supportsCompanionRadioStats)
const RadioStatsIconButton(compact: true),
],
),
trailing ?? const SizedBox.shrink(),
+78 -11
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/path_helper.dart';
import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
@@ -33,14 +34,26 @@ class _PathManagementDialog extends StatefulWidget {
class _PathManagementDialogState extends State<_PathManagementDialog> {
bool _showAllPaths = false;
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
String _formatRelativeTime(BuildContext context, DateTime time) {
String _formatRelativeTime(BuildContext context, DateTime? time) {
if (time == null) return '';
final l10n = context.l10n;
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return l10n.time_justNow;
@@ -61,15 +74,31 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
return;
}
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
final connector = context.read<MeshCoreConnector>();
final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.chat_fullPath),
content: SelectableText(formattedPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.push(
@@ -80,6 +109,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
path: Uint8List.fromList(pathBytes),
flipPathAround: true,
targetContact: widget.contact,
pathHashByteWidth: connector.pathHashByteWidth,
),
),
),
@@ -106,7 +136,9 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
connector.getContacts();
}
final pathForInput = currentContact.pathIdList;
final pathForInput = currentContact.pathFormattedIdList(
connector.pathHashByteWidth,
);
final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();
@@ -262,16 +294,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
radius: 16,
backgroundColor: color,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
path.routeWeight.toStringAsFixed(1),
style: const TextStyle(fontSize: 10),
),
),
title: Text(
l10n.chat_hopsCount(path.hopCount),
style: const TextStyle(fontSize: 14),
),
isThreeLine: true,
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@@ -346,6 +379,40 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
Text(l10n.chat_noPathHistoryYet),
const Divider(),
],
// Flood delivery stats
Builder(
builder: (context) {
final floodStats = pathService.getFloodStats(
currentContact.publicKeyHex,
);
if (floodStats == null ||
(floodStats.successCount == 0 &&
floodStats.failureCount == 0)) {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text(
'Flood Mode',
style: TextStyle(fontSize: 14),
),
subtitle: Text(
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
'${floodStats.lastTripTimeMs > 0 ? '${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
'${floodStats.lastUsed != null ? '${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
style: const TextStyle(fontSize: 11),
),
),
);
},
),
const SizedBox(height: 8),
Text(
l10n.chat_pathActions,
+147
View File
@@ -0,0 +1,147 @@
import 'dart:async';
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/screens/companion_radio_stats_screen.dart';
import 'package:provider/provider.dart';
void pushCompanionRadioStatsScreen(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (context) => const CompanionRadioStatsScreen(),
),
);
}
class RadioStatsIconButton extends StatefulWidget {
final bool compact;
const RadioStatsIconButton({super.key, this.compact = false});
@override
State<RadioStatsIconButton> createState() => _RadioStatsIconButtonState();
}
class _RadioStatsIconButtonState extends State<RadioStatsIconButton> {
MeshCoreConnector? _connector;
@override
void initState() {
super.initState();
final c = context.read<MeshCoreConnector>();
_connector = c;
c.acquireRadioStatsPolling();
}
@override
void dispose() {
_connector?.releaseRadioStatsPolling();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Selector<MeshCoreConnector, ({bool connected, bool supported})>(
selector: (_, c) =>
(connected: c.isConnected, supported: c.supportsCompanionRadioStats),
builder: (context, state, _) {
if (!state.connected || !state.supported) {
return const SizedBox.shrink();
}
final connector = context.read<MeshCoreConnector>();
return ValueListenableBuilder<CompanionRadioStats?>(
valueListenable: connector.radioStatsNotifier,
builder: (context, _, child) {
final dot = AirActivityDot(
active: connector.radioStatsAirActivityPulse,
);
if (widget.compact) {
return GestureDetector(
onTap: () => pushCompanionRadioStatsScreen(context),
child: Padding(
padding: const EdgeInsets.only(left: 4),
child: dot,
),
);
}
return Tooltip(
message: context.l10n.radioStats_tooltip,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => pushCompanionRadioStatsScreen(context),
child: SizedBox(
width: 48,
height: 48,
child: Center(child: dot),
),
),
);
},
);
},
);
}
}
class AirActivityDot extends StatefulWidget {
final bool active;
const AirActivityDot({super.key, required this.active});
@override
State<AirActivityDot> createState() => AirActivityDotState();
}
class AirActivityDotState extends State<AirActivityDot> {
Timer? _timer;
bool _blink = true;
@override
void initState() {
super.initState();
if (widget.active) _startTimer();
}
@override
void didUpdateWidget(covariant AirActivityDot oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active && !oldWidget.active) {
_startTimer();
} else if (!widget.active && oldWidget.active) {
_stopTimer();
_blink = true;
}
}
void _startTimer() {
_timer ??= Timer.periodic(const Duration(milliseconds: 400), (_) {
if (!mounted) return;
setState(() => _blink = !_blink);
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final on = widget.active && _blink;
return Icon(
Icons.circle,
size: 12,
color: on ? scheme.primary : scheme.outline,
);
}
}
+12 -2
View File
@@ -69,11 +69,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _isLoggingIn = false;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
Future<void> _handleLogin() async {
+13 -2
View File
@@ -64,11 +64,22 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
bool _isLoggingIn = false;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.room.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.room.publicKeyHex,
orElse: () => widget.room,
);
if (_resolveRepeaterIndex == -1) {
return widget.room;
}
return connector.contacts[_resolveRepeaterIndex];
}
Future<void> _handleLogin() async {