mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Finish discovery flow
ref:
This commit is contained in:
@@ -180,15 +180,23 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
bool _telemetryEnabled = false;
|
||||
bool _repeatEnabled = false;
|
||||
bool _autoAddDiscoveredContactsEnabled = true;
|
||||
bool _autoAddUsersEnabled = true;
|
||||
bool _autoAddRepeatersEnabled = true;
|
||||
bool _autoAddRoomServersEnabled = true;
|
||||
bool _autoAddSensorsEnabled = true;
|
||||
bool _overwriteOldestAutoAddEnabled = false;
|
||||
bool _showCustomRadioSettings = false;
|
||||
bool _isSavingPublicInfo = false;
|
||||
bool _isSavingRadioSettings = false;
|
||||
bool _isSavingAutoDiscoverySettings = false;
|
||||
bool _isClearingContacts = false;
|
||||
bool _isClearingChannels = false;
|
||||
bool _publicInfoSaved = false;
|
||||
bool _radioSettingsSaved = false;
|
||||
bool _autoDiscoverySettingsSaved = false;
|
||||
String? _publicInfoError;
|
||||
String? _radioSettingsError;
|
||||
String? _autoDiscoverySettingsError;
|
||||
String _selectedBandwidth = '62.5 kHz';
|
||||
int _selectedSpreadingFactor = 8;
|
||||
int _selectedCodingRate = 8;
|
||||
@@ -267,6 +275,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
||||
_autoAddDiscoveredContactsEnabled =
|
||||
!(deviceInfo.manualAddContacts ?? false);
|
||||
_autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true;
|
||||
_autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true;
|
||||
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
|
||||
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
|
||||
_overwriteOldestAutoAddEnabled =
|
||||
deviceInfo.autoAddOverwriteOldest ?? false;
|
||||
|
||||
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
||||
if (deviceInfo.clientRepeat != null &&
|
||||
@@ -278,6 +292,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ConnectionProvider>().getBatteryAndStorage();
|
||||
context.read<ConnectionProvider>().getAutoaddConfig();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -390,6 +405,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _markAutoDiscoverySettingsDirty() {
|
||||
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
|
||||
setState(() {
|
||||
_autoDiscoverySettingsSaved = false;
|
||||
_autoDiscoverySettingsError = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _savePublicInfo() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final validator = ValidationService();
|
||||
@@ -401,7 +425,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1;
|
||||
final manualAddContacts =
|
||||
(connectionProvider.deviceInfo.manualAddContacts ?? false) ? 1 : 0;
|
||||
|
||||
// Save name
|
||||
if (_nameController.text.isNotEmpty) {
|
||||
@@ -553,6 +578,49 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveAutoDiscoverySettings() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
|
||||
setState(() {
|
||||
_isSavingAutoDiscoverySettings = true;
|
||||
_autoDiscoverySettingsSaved = false;
|
||||
_autoDiscoverySettingsError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await connectionProvider.setOtherParams(
|
||||
manualAddContacts: _autoAddDiscoveredContactsEnabled ? 0 : 1,
|
||||
telemetryModes: connectionProvider.deviceInfo.telemetryModes ?? 0,
|
||||
advertLocationPolicy: connectionProvider.deviceInfo.advertLocPolicy ?? 0,
|
||||
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0,
|
||||
);
|
||||
await connectionProvider.setAutoaddConfig(
|
||||
autoAddUsers: _autoAddUsersEnabled,
|
||||
autoAddRepeaters: _autoAddRepeatersEnabled,
|
||||
autoAddRoomServers: _autoAddRoomServersEnabled,
|
||||
autoAddSensors: _autoAddSensorsEnabled,
|
||||
overwriteOldest: _overwriteOldestAutoAddEnabled,
|
||||
);
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSavingAutoDiscoverySettings = false;
|
||||
_autoDiscoverySettingsSaved = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSavingAutoDiscoverySettings = false;
|
||||
_autoDiscoverySettingsError = AppLocalizations.of(
|
||||
context,
|
||||
)!.failedToSave(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _useCurrentLocation() async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
@@ -997,9 +1065,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_ConfigSectionCard(
|
||||
title: AppLocalizations.of(context)!.publicInfo,
|
||||
subtitle: 'Choose the name and location this device shares.',
|
||||
icon: Icons.public_rounded,
|
||||
title: 'Auto discovery',
|
||||
subtitle:
|
||||
'Control how the radio auto-adds discovered nodes to its contacts table.',
|
||||
icon: Icons.person_search_rounded,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -1007,9 +1076,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
icon: _autoAddDiscoveredContactsEnabled
|
||||
? Icons.person_add_alt_1
|
||||
: Icons.person_add_disabled,
|
||||
title: 'Auto-add discovered contacts',
|
||||
title: 'Enable automatic adding',
|
||||
description:
|
||||
'Control whether the device automatically stores newly discovered contacts.',
|
||||
'Turn this off to keep discoveries manual-only on the radio.',
|
||||
accentColor: _autoAddDiscoveredContactsEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
@@ -1018,13 +1087,148 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_autoAddDiscoveredContactsEnabled = value;
|
||||
_publicInfoSaved = false;
|
||||
_publicInfoError = null;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_SettingHighlightCard(
|
||||
icon: Icons.person_outline_rounded,
|
||||
title: 'Auto-add users',
|
||||
description:
|
||||
'Automatically store discovered user/chat nodes.',
|
||||
accentColor: _autoAddUsersEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
trailing: Switch(
|
||||
value: _autoAddUsersEnabled,
|
||||
onChanged: _autoAddDiscoveredContactsEnabled
|
||||
? (value) {
|
||||
setState(() {
|
||||
_autoAddUsersEnabled = value;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SettingHighlightCard(
|
||||
icon: Icons.router_outlined,
|
||||
title: 'Auto-add repeaters',
|
||||
description:
|
||||
'Automatically store discovered repeater nodes.',
|
||||
accentColor: _autoAddRepeatersEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
trailing: Switch(
|
||||
value: _autoAddRepeatersEnabled,
|
||||
onChanged: _autoAddDiscoveredContactsEnabled
|
||||
? (value) {
|
||||
setState(() {
|
||||
_autoAddRepeatersEnabled = value;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SettingHighlightCard(
|
||||
icon: Icons.meeting_room_outlined,
|
||||
title: 'Auto-add room servers',
|
||||
description:
|
||||
'Automatically store discovered room/server nodes.',
|
||||
accentColor: _autoAddRoomServersEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
trailing: Switch(
|
||||
value: _autoAddRoomServersEnabled,
|
||||
onChanged: _autoAddDiscoveredContactsEnabled
|
||||
? (value) {
|
||||
setState(() {
|
||||
_autoAddRoomServersEnabled = value;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SettingHighlightCard(
|
||||
icon: Icons.sensors_outlined,
|
||||
title: 'Auto-add sensors',
|
||||
description:
|
||||
'Automatically store discovered sensor nodes.',
|
||||
accentColor: _autoAddSensorsEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
trailing: Switch(
|
||||
value: _autoAddSensorsEnabled,
|
||||
onChanged: _autoAddDiscoveredContactsEnabled
|
||||
? (value) {
|
||||
setState(() {
|
||||
_autoAddSensorsEnabled = value;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SettingHighlightCard(
|
||||
icon: Icons.history_toggle_off_rounded,
|
||||
title: 'Overwrite oldest when full',
|
||||
description:
|
||||
'Allow the radio to replace the oldest contact when storage is full.',
|
||||
accentColor: _overwriteOldestAutoAddEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
trailing: Switch(
|
||||
value: _overwriteOldestAutoAddEnabled,
|
||||
onChanged: _autoAddDiscoveredContactsEnabled
|
||||
? (value) {
|
||||
setState(() {
|
||||
_overwriteOldestAutoAddEnabled = value;
|
||||
_markAutoDiscoverySettingsDirty();
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (_autoDiscoverySettingsError != null) ...[
|
||||
Text(
|
||||
_autoDiscoverySettingsError!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: _SaveActionButton(
|
||||
onPressed: _isSavingAutoDiscoverySettings
|
||||
? null
|
||||
: _saveAutoDiscoverySettings,
|
||||
isSaving: _isSavingAutoDiscoverySettings,
|
||||
isSaved: _autoDiscoverySettingsSaved,
|
||||
label: 'Save discovery settings',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_ConfigSectionCard(
|
||||
title: AppLocalizations.of(context)!.publicInfo,
|
||||
subtitle: 'Choose the name and location this device shares.',
|
||||
icon: Icons.public_rounded,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingHighlightCard(
|
||||
icon: _telemetryEnabled
|
||||
? Icons.travel_explore
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||
|
||||
enum _DiscoveryMenuAction { repeaters, sensors }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
@@ -15,7 +19,10 @@ class DiscoveryScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
static const int _repeaterAdvertType = 2;
|
||||
static const int _sensorAdvertType = 4;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||
bool _isResolvingAll = false;
|
||||
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||
|
||||
@@ -27,6 +34,85 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
||||
switch (action) {
|
||||
case _DiscoveryMenuAction.repeaters:
|
||||
await _discoverNodeType(_repeaterAdvertType);
|
||||
break;
|
||||
case _DiscoveryMenuAction.sensors:
|
||||
await _discoverNodeType(_sensorAdvertType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAllDiscoveries() async {
|
||||
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
|
||||
if (pendingCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Clear discoveries'),
|
||||
content: Text(
|
||||
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Clear all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.read<ContactsProvider>().clearPendingAdverts();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cleared pending discoveries.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _discoverNodeType(int advertType) async {
|
||||
if (_runningDiscoveryTypes.contains(advertType)) return;
|
||||
|
||||
setState(() {
|
||||
_runningDiscoveryTypes.add(advertType);
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<ConnectionProvider>().discoverNodeType(
|
||||
advertType: advertType,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final label = switch (advertType) {
|
||||
_repeaterAdvertType => 'Repeater discovery sent',
|
||||
_sensorAdvertType => 'Sensor discovery sent',
|
||||
_ => 'Discovery sent',
|
||||
};
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(label)));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_runningDiscoveryTypes.remove(advertType);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveAdvert(PendingAdvert advert) async {
|
||||
final keyHex = advert.publicKeyHex;
|
||||
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||
@@ -36,7 +122,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<ConnectionProvider>().getContact(advert.publicKey);
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
await connectionProvider.getContact(advert.publicKey);
|
||||
if (connectionProvider.error == 'Not found') {
|
||||
connectionProvider.clearError();
|
||||
final fallbackContact = _contactFromPendingAdvert(advert);
|
||||
await connectionProvider.addOrUpdateContact(fallbackContact);
|
||||
contactsProvider.addOrUpdateContact(
|
||||
fallbackContact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
}
|
||||
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -76,11 +177,39 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
Contact _contactFromPendingAdvert(PendingAdvert advert) {
|
||||
final lastAdvert =
|
||||
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
|
||||
final advName = advert.advName?.trim().isNotEmpty == true
|
||||
? advert.advName!.trim()
|
||||
: advert.shortDisplayKey;
|
||||
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(advert.publicKey),
|
||||
type: ContactType.fromValue(advert.typeValue ?? 0),
|
||||
flags: advert.flags ?? 0,
|
||||
outPathLen: advert.signedEncodedPathLen ?? -1,
|
||||
outPath: advert.paddedPathBytes == null
|
||||
? Uint8List(64)
|
||||
: Uint8List.fromList(advert.paddedPathBytes!),
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advert.advLat ?? 0,
|
||||
advLon: advert.advLon ?? 0,
|
||||
lastMod: lastAdvert,
|
||||
);
|
||||
}
|
||||
|
||||
String _displayNameForAdvert(
|
||||
PendingAdvert advert,
|
||||
ContactsProvider contactsProvider,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final advertisedName = advert.advName?.trim();
|
||||
if (advertisedName != null && advertisedName.isNotEmpty) {
|
||||
return advertisedName;
|
||||
}
|
||||
|
||||
Contact? existingMatch;
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (contact.publicKeyHex == advert.publicKeyHex) {
|
||||
@@ -106,12 +235,144 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
return advert.shortDisplayKey;
|
||||
}
|
||||
|
||||
IconData _iconForAdvert(PendingAdvert advert) {
|
||||
return switch (advert.typeValue) {
|
||||
_repeaterAdvertType => Icons.router_outlined,
|
||||
_sensorAdvertType => Icons.sensors_outlined,
|
||||
_ => Icons.campaign_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
String? _typeLabelForAdvert(PendingAdvert advert) {
|
||||
return switch (advert.typeValue) {
|
||||
_repeaterAdvertType => 'Repeater',
|
||||
_sensorAdvertType => 'Sensor',
|
||||
3 => 'Room',
|
||||
1 => 'Chat',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String? _resolvedTypeLabelForAdvert(
|
||||
PendingAdvert advert,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final directType = _typeLabelForAdvert(advert);
|
||||
if (directType != null) {
|
||||
return directType;
|
||||
}
|
||||
|
||||
for (final node in cachedNodes) {
|
||||
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||
return switch (node.type) {
|
||||
1 => 'Repeater',
|
||||
4 => 'Sensor',
|
||||
3 => 'Room',
|
||||
2 => 'Chat',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildAdvertTitle(
|
||||
BuildContext context, {
|
||||
required String displayName,
|
||||
String? subtitle,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Discovery')),
|
||||
appBar: AppBar(
|
||||
title: const Text('Discovery'),
|
||||
actions: [
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, connectionProvider, child) {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||
_repeaterAdvertType,
|
||||
);
|
||||
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||
_sensorAdvertType,
|
||||
);
|
||||
|
||||
return PopupMenuButton<_DiscoveryMenuAction>(
|
||||
tooltip: 'Discovery tools',
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.repeaters,
|
||||
enabled: isConnected && !repeatersBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
repeatersBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.router_outlined),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Discover repeaters'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.sensors,
|
||||
enabled: isConnected && !sensorsBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
sensorsBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.sensors_outlined),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Discover sensors'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<MeshMapNode>>(
|
||||
future: _cachedNodesFuture,
|
||||
builder: (context, nodesSnapshot) =>
|
||||
@@ -121,65 +382,98 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
|
||||
if (pendingAdverts.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending discoveries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unknown adverts will appear here until you choose to resolve them.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_search),
|
||||
title: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
),
|
||||
trailing: FilledButton.icon(
|
||||
onPressed: isConnected && !_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.download_for_offline_outlined),
|
||||
label: const Text('Resolve all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_for_offline_outlined,
|
||||
),
|
||||
label: const Text('Resolve all'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: const Text('Clear all'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
if (pendingAdverts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending discoveries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unknown adverts will appear here until you choose to resolve them.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) {
|
||||
final isResolving = _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
@@ -189,35 +483,92 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||
advert,
|
||||
cachedNodes,
|
||||
);
|
||||
final downMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.rxRssiDbm,
|
||||
snrDb: advert.rxSnr,
|
||||
);
|
||||
final upMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.repeaterLastRssi,
|
||||
snrDb: advert.repeaterLastSnr,
|
||||
);
|
||||
final detailLines = <String>[
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||
];
|
||||
final summaryParts = <String>[];
|
||||
final battery = advert.repeaterBatteryPercent;
|
||||
if (battery != null) {
|
||||
summaryParts.add('Battery ${battery.round()}%');
|
||||
}
|
||||
if (advert.repeaterQueueLen != null) {
|
||||
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||
}
|
||||
if (summaryParts.isNotEmpty) {
|
||||
detailLines.add(summaryParts.join(' • '));
|
||||
}
|
||||
detailLines.add(
|
||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const CircleAvatar(
|
||||
child: Icon(Icons.campaign_outlined),
|
||||
),
|
||||
title: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}\n'
|
||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
),
|
||||
trailing: isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
child: Icon(_iconForAdvert(advert)),
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: isConnected
|
||||
? () => _resolveAdvert(advert)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildAdvertTitle(
|
||||
context,
|
||||
displayName: displayName,
|
||||
subtitle: typeLabel,
|
||||
),
|
||||
),
|
||||
if (downMetric != null || upMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildSignalSummary(
|
||||
context,
|
||||
downMetric: downMetric,
|
||||
upMetric: upMetric,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(
|
||||
Icons.person_add_alt_1,
|
||||
),
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: isConnected
|
||||
? () => _resolveAdvert(advert)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
detailLines.join('\n'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -228,4 +579,82 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalSummary(
|
||||
BuildContext context, {
|
||||
SignalMetric? downMetric,
|
||||
SignalMetric? upMetric,
|
||||
}) {
|
||||
if (downMetric == null && upMetric == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (downMetric != null)
|
||||
_buildDirectionalSignal(
|
||||
context,
|
||||
icon: Icons.south_west_rounded,
|
||||
metric: downMetric,
|
||||
),
|
||||
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
|
||||
if (upMetric != null)
|
||||
_buildDirectionalSignal(
|
||||
context,
|
||||
icon: Icons.north_east_rounded,
|
||||
metric: upMetric,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionalSignal(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required SignalMetric metric,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
_buildMiniSignalBars(context, metric),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
metric.valueLabel,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
|
||||
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var index = 0; index < 3; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 2),
|
||||
Container(
|
||||
width: 3,
|
||||
height: 6.0 + (index * 4),
|
||||
decoration: BoxDecoration(
|
||||
color: index < metric.activeBars ? metric.color : inactive,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../services/live_traffic_summary.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
import '../widgets/compact_signal_indicator.dart';
|
||||
import '../widgets/messages/message_trace_sheet.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
|
||||
T? _maybeProvider<T>(BuildContext context) {
|
||||
@@ -650,110 +652,101 @@ class _LiveTrafficCard extends StatelessWidget {
|
||||
final rxInfo = log.logRxDataInfo;
|
||||
final originDistance = _originDistanceLabel(context, entry);
|
||||
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
|
||||
final signalMetric = _SignalMetric.fromRxInfo(rxInfo);
|
||||
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||
onLongPress: () => _showTraceSheet(context, entry),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
isRx ? 'RX' : 'TX',
|
||||
style: TextStyle(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
packetDetails.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
isRx ? 'RX' : 'TX',
|
||||
style: TextStyle(
|
||||
color: accent,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (entry.payloadMeaning != null)
|
||||
Text(
|
||||
entry.payloadMeaning!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (signalMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_CompactSignalIndicator(metric: signalMetric),
|
||||
] else
|
||||
Text(
|
||||
_timeAgo(log.timestamp, now),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_PacketInfoLine(
|
||||
text:
|
||||
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
|
||||
),
|
||||
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
|
||||
if (packetDetails.pathLine != null)
|
||||
_PacketInfoLine(text: packetDetails.pathLine!),
|
||||
if (packetDetails.pathHashLine != null)
|
||||
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||
if (packetDetails.endpointLine != null)
|
||||
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_PacketMetaChip(
|
||||
label: '${log.rawData.length} bytes',
|
||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
packetDetails.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (entry.payloadMeaning != null)
|
||||
Text(
|
||||
entry.payloadMeaning!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (signalMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
CompactSignalIndicator(metric: signalMetric),
|
||||
] else
|
||||
Text(
|
||||
_timeAgo(log.timestamp, now),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (entry.isMultiHop)
|
||||
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
||||
const SizedBox(height: 10),
|
||||
_PacketInfoLine(
|
||||
text:
|
||||
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
|
||||
),
|
||||
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
|
||||
if (packetDetails.pathLine != null)
|
||||
_PacketInfoLine(text: packetDetails.pathLine!),
|
||||
if (packetDetails.pathHashLine != null)
|
||||
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||
if (packetDetails.endpointLine != null)
|
||||
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||
if (originDistance != null)
|
||||
_PacketMetaChip(label: 'Origin $originDistance'),
|
||||
if (rxInfo?.rssiDbm != null)
|
||||
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
|
||||
if (rxInfo?.snrDb != null)
|
||||
_PacketMetaChip(
|
||||
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
||||
),
|
||||
_PacketInfoLine(text: 'Origin: $originDistance'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -771,38 +764,6 @@ class _LiveTrafficCard extends StatelessWidget {
|
||||
return '$hour:$minute:$second';
|
||||
}
|
||||
|
||||
static String _resolvedRoutePreview(
|
||||
BuildContext context,
|
||||
LiveTrafficEntry entry,
|
||||
) {
|
||||
final route = entry.route;
|
||||
if (route == null || route.hopHashes.isEmpty) {
|
||||
return entry.routePreview;
|
||||
}
|
||||
|
||||
final contactsProvider = _maybeProvider<ContactsProvider>(context);
|
||||
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
|
||||
if (contactsProvider == null && connectionProvider == null) {
|
||||
return entry.routePreview;
|
||||
}
|
||||
final ownLatLng = _ownLatLng(connectionProvider);
|
||||
|
||||
final resolvedLabels = route.hopHashes.map((hashHex) {
|
||||
final resolved = LogRxRouteDecoder.resolveHash(
|
||||
hashHex,
|
||||
contacts: contactsProvider?.contacts ?? const <Contact>[],
|
||||
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
|
||||
ownName:
|
||||
connectionProvider?.deviceInfo.selfName ??
|
||||
connectionProvider?.deviceInfo.displayName,
|
||||
ownLatitude: ownLatLng?.latitude,
|
||||
ownLongitude: ownLatLng?.longitude,
|
||||
);
|
||||
return _compactNodeLabel(resolved);
|
||||
}).toList();
|
||||
return resolvedLabels.join(' -> ');
|
||||
}
|
||||
|
||||
static String? _originDistanceLabel(
|
||||
BuildContext context,
|
||||
LiveTrafficEntry entry,
|
||||
@@ -962,15 +923,31 @@ class _LiveTrafficCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _compactNodeLabel(ResolvedNodeHash node) {
|
||||
if (node.isOwnNode) {
|
||||
return node.label;
|
||||
static Future<void> _showTraceSheet(
|
||||
BuildContext context,
|
||||
LiveTrafficEntry entry,
|
||||
) {
|
||||
final route = entry.route;
|
||||
if (route == null || route.pathBytes.isEmpty) {
|
||||
return Future.value();
|
||||
}
|
||||
if (node.matchCount > 0) {
|
||||
return node.label;
|
||||
}
|
||||
return node.hexLabel;
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (context) => MessageTraceSheet.packetPath(
|
||||
packetPath: route.pathBytes,
|
||||
descriptionOverride:
|
||||
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
|
||||
noRelayMatchTextOverride:
|
||||
'No named nodes could be matched for this packet path.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class _LiveTrafficPacketDetails {
|
||||
@@ -1129,96 +1106,6 @@ class _PacketInfoLine extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SignalMetric {
|
||||
final String valueLabel;
|
||||
final Color color;
|
||||
final int activeBars;
|
||||
|
||||
const _SignalMetric({
|
||||
required this.valueLabel,
|
||||
required this.color,
|
||||
required this.activeBars,
|
||||
});
|
||||
|
||||
static _SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
|
||||
if (rxInfo == null) return null;
|
||||
if (rxInfo?.snrDb != null) {
|
||||
final snr = rxInfo.snrDb!;
|
||||
return _SignalMetric(
|
||||
valueLabel: '${snr.toStringAsFixed(1)}dB',
|
||||
color: snr >= 10
|
||||
? Colors.green
|
||||
: snr >= 0
|
||||
? Colors.amber
|
||||
: Colors.redAccent,
|
||||
activeBars: snr >= 10
|
||||
? 3
|
||||
: snr >= 0
|
||||
? 2
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
if (rxInfo?.rssiDbm != null) {
|
||||
final rssi = rxInfo.rssiDbm!;
|
||||
return _SignalMetric(
|
||||
valueLabel: '$rssi dBm',
|
||||
color: rssi >= -80
|
||||
? Colors.green
|
||||
: rssi >= -95
|
||||
? Colors.amber
|
||||
: Colors.redAccent,
|
||||
activeBars: rssi >= -80
|
||||
? 3
|
||||
: rssi >= -95
|
||||
? 2
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactSignalIndicator extends StatelessWidget {
|
||||
final _SignalMetric metric;
|
||||
|
||||
const _CompactSignalIndicator({required this.metric});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var index = 0; index < 3; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 3),
|
||||
Container(
|
||||
width: 5,
|
||||
height: 10.0 + (index * 8),
|
||||
decoration: BoxDecoration(
|
||||
color: index < metric.activeBars ? metric.color : inactive,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
metric.valueLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GeoPoint {
|
||||
final double latitude;
|
||||
@@ -1227,44 +1114,6 @@ class _GeoPoint {
|
||||
const _GeoPoint(this.latitude, this.longitude);
|
||||
}
|
||||
|
||||
class _PacketMetaChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool emphasized;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PacketMetaChip({
|
||||
required this.label,
|
||||
this.emphasized = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final color = emphasized ? scheme.primary : scheme.outline;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.22)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
||||
@@ -519,6 +519,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
Contact? senderContact;
|
||||
String? senderDisplayName;
|
||||
|
||||
String destinationType;
|
||||
Contact? recipient;
|
||||
@@ -543,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
}
|
||||
senderDisplayName =
|
||||
senderContact?.displayName ?? message.senderName?.trim();
|
||||
} else {
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||
return;
|
||||
}
|
||||
final roomRecipient = message.recipientPublicKey == null
|
||||
? null
|
||||
: contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||
|
||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
if (recipient == null) {
|
||||
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||
return;
|
||||
}
|
||||
if (roomRecipient?.isRoom == true) {
|
||||
destinationType = MessageDestinationPreferences.destinationTypeRoom;
|
||||
recipient = roomRecipient;
|
||||
|
||||
destinationType = recipient.isRoom
|
||||
? MessageDestinationPreferences.destinationTypeRoom
|
||||
: MessageDestinationPreferences.destinationTypeContact;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
}
|
||||
senderDisplayName =
|
||||
senderContact?.displayName ?? message.senderName?.trim();
|
||||
} else {
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||
return;
|
||||
}
|
||||
|
||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
if (recipient == null) {
|
||||
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
destinationType = recipient.isRoom
|
||||
? MessageDestinationPreferences.destinationTypeRoom
|
||||
: MessageDestinationPreferences.destinationTypeContact;
|
||||
}
|
||||
}
|
||||
|
||||
await _onRecipientSelected(destinationType, recipient);
|
||||
if (!mounted) return;
|
||||
if (message.isChannelMessage && senderContact != null) {
|
||||
_insertReplyMention(senderContact.displayName);
|
||||
if ((message.isChannelMessage || recipient?.isRoom == true) &&
|
||||
senderDisplayName != null &&
|
||||
senderDisplayName.isNotEmpty) {
|
||||
_insertReplyMention(senderDisplayName);
|
||||
}
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user