mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
Merge branch 'main' into ez_location-snr
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user