mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-10 18:47:15 +10:00
Merge branch 'main' into feature/usb
This commit is contained in:
@@ -818,6 +818,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathRound: true,
|
||||
targetContact: widget.contact,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ import '../widgets/room_login_dialog.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'discovery_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
@@ -218,9 +219,10 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
}
|
||||
final hexString = text.substring('meshcore://'.length);
|
||||
try {
|
||||
final importContactFrame = buildImportContactFrame(hexString);
|
||||
final bytes = hex2Uint8List(hexString);
|
||||
final importContactFrame = buildImportContactFrame(bytes);
|
||||
_pendingOperations.add(ContactOperationType.import);
|
||||
await connector.sendFrame(importContactFrame, expectsGenericAck: true);
|
||||
connector.importContact(importContactFrame);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -318,6 +320,21 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
),
|
||||
onTap: () => _disconnect(context, connector),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person_add_rounded),
|
||||
const SizedBox(width: 8),
|
||||
Text("Discovered Contacts"),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DiscoveryScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -1121,6 +1138,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
targetContact: contact,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/discovery_contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
|
||||
enum DiscoverySortOption { lastSeen, name, type }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String searchQuery = '';
|
||||
ContactSortOption sortOption = ContactSortOption.lastSeen;
|
||||
bool showUnreadOnly = false;
|
||||
ContactTypeFilter typeFilter = ContactTypeFilter.all;
|
||||
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
final discoveredContacts = connector.discoveredContacts;
|
||||
final filteredAndSorted = _filterAndSortContacts(
|
||||
discoveredContacts,
|
||||
connector,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppBarTitle(
|
||||
l10n.discoveredContacts_Title,
|
||||
indicators: false,
|
||||
subtitle: false,
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.discoveredContacts_deleteContactAll),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_deleteContacts(context, connector);
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilters(filteredAndSorted, connector),
|
||||
Expanded(
|
||||
child: discoveredContacts.isEmpty
|
||||
? Center(child: Text(l10n.contacts_noContacts))
|
||||
: filteredAndSorted.isEmpty
|
||||
? Center(child: Text(l10n.discoveredContacts_noMatching))
|
||||
: ListView.builder(
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatLastSeen(context, contact.lastSeen),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
connector.importDiscoveredContact(contact);
|
||||
},
|
||||
onLongPress: () =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showContactContextMenu(
|
||||
DiscoveryContact contact,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final l10n = context.l10n;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_sharp),
|
||||
title: Text(l10n.discoveredContacts_addContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('import_contact'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(l10n.discoveredContacts_copyContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('copy_contact'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete),
|
||||
title: Text(l10n.discoveredContacts_deleteContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted || action == null) return;
|
||||
|
||||
switch (action) {
|
||||
case 'import_contact':
|
||||
connector.importDiscoveredContact(contact);
|
||||
break;
|
||||
case 'copy_contact':
|
||||
final hexString = pubKeyToHex(contact.rawPacket);
|
||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
|
||||
);
|
||||
break;
|
||||
case 'delete_contact':
|
||||
connector.removeDiscoveredContact(contact);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteContacts(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.common_deleteAll),
|
||||
content: Text(l10n.discoveredContacts_deleteContactAllContent),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
connector.removeAllDiscoveredContacts();
|
||||
},
|
||||
child: Text(l10n.common_deleteAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(
|
||||
List<DiscoveryContact> filteredAndSorted,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
String hintText = "";
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
hintText = context.l10n.contacts_searchContacts(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.users:
|
||||
hintText = context.l10n.contacts_searchUsers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.repeaters:
|
||||
hintText = context.l10n.contacts_searchRepeaters(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.rooms:
|
||||
hintText = context.l10n.contacts_searchRoomServers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.favorites:
|
||||
hintText = context.l10n.contacts_searchFavorites(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
searchQuery = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
searchQuery = value.toLowerCase();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
return DiscoveryContactsFilterMenu(
|
||||
sortOption: sortOption,
|
||||
typeFilter: typeFilter,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
sortOption = value;
|
||||
});
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
typeFilter = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<DiscoveryContact> _filterAndSortContacts(
|
||||
List<DiscoveryContact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (searchQuery.isEmpty) return true;
|
||||
return matchesDiscoveryContactQuery(contact, searchQuery);
|
||||
}).toList();
|
||||
|
||||
filtered = filtered.where((contact) {
|
||||
return !connector.knownContactKeys.contains(contact.publicKeyHex);
|
||||
}).toList();
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
filtered = filtered.where((contact) {
|
||||
return contact.publicKeyHex != selfPubKeyHex;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
|
||||
switch (sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
break;
|
||||
case ContactSortOption.name:
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(DiscoveryContact contact) {
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.users:
|
||||
return contact.type == advTypeChat;
|
||||
case ContactTypeFilter.repeaters:
|
||||
return contact.type == advTypeRepeater;
|
||||
case ContactTypeFilter.rooms:
|
||||
return contact.type == advTypeRoom;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.chat;
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.group;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.orange;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.isNegative || diff.inMinutes < 5) {
|
||||
return context.l10n.contacts_lastSeenNow;
|
||||
}
|
||||
if (diff.inMinutes < 60) {
|
||||
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||
}
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return hours == 1
|
||||
? context.l10n.contacts_lastSeenHourAgo
|
||||
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return days == 1
|
||||
? context.l10n.contacts_lastSeenDayAgo
|
||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||
}
|
||||
}
|
||||
+284
-8
@@ -15,6 +15,7 @@ import '../models/app_settings.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/path_history_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
@@ -64,6 +65,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
final List<Polyline> _polylines = [];
|
||||
bool _legendExpanded = false;
|
||||
bool _showNodeLabels = true;
|
||||
List<_GuessedLocation> _cachedGuessedLocations = [];
|
||||
String _guessedLocationsCacheKey = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -119,8 +122,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
||||
builder: (context, connector, settingsService, child) {
|
||||
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
|
||||
builder: (context, connector, settingsService, pathHistory, child) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final settings = settingsService.settings;
|
||||
final contacts = connector.contacts;
|
||||
@@ -160,6 +163,40 @@ class _MapScreenState extends State<MapScreen> {
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// All contacts with a known location — used as anchors regardless of
|
||||
// time/key-prefix filters so that repeaters are always available.
|
||||
final allContactsWithLocation = contacts
|
||||
.where((c) => c.hasLocation)
|
||||
.toList();
|
||||
|
||||
// Compute guessed locations with caching
|
||||
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
||||
final filteredKeys = filteredByKeyPrefix
|
||||
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
||||
.join(',');
|
||||
final anchorKeys = allContactsWithLocation
|
||||
.map(
|
||||
(c) =>
|
||||
'${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}',
|
||||
)
|
||||
.join(',');
|
||||
final cacheKey =
|
||||
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
||||
if (cacheKey != _guessedLocationsCacheKey) {
|
||||
_guessedLocationsCacheKey = cacheKey;
|
||||
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
||||
? _computeGuessedLocations(
|
||||
filteredByKeyPrefix,
|
||||
allContactsWithLocation,
|
||||
pathHistory,
|
||||
maxRangeKm,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
final guessedLocations = settings.mapShowGuessedLocations
|
||||
? _cachedGuessedLocations
|
||||
: <_GuessedLocation>[];
|
||||
|
||||
_polylines.clear();
|
||||
_polylines.addAll(
|
||||
_points.length > 1
|
||||
@@ -430,6 +467,8 @@ class _MapScreenState extends State<MapScreen> {
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
if (!_isBuildingPathTrace)
|
||||
...guessedLocations.map(_buildGuessedMarker),
|
||||
..._buildMarkers(
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
@@ -489,6 +528,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
contactsWithLocation,
|
||||
settings,
|
||||
sharedMarkers.length,
|
||||
guessedLocations.length,
|
||||
),
|
||||
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
||||
],
|
||||
@@ -512,6 +552,200 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<_GuessedLocation> _computeGuessedLocations(
|
||||
List<Contact> allContacts,
|
||||
List<Contact> withLocation,
|
||||
PathHistoryService pathHistory,
|
||||
double? maxRangeKm,
|
||||
) {
|
||||
// Index known-location repeaters by their 1-byte hash.
|
||||
// null value = two repeaters share the same hash byte (ambiguous collision).
|
||||
final repeaterByHash = <int, Contact?>{};
|
||||
for (final c in withLocation) {
|
||||
if (c.type == advTypeRepeater) {
|
||||
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
||||
repeaterByHash[c.publicKey[0]] =
|
||||
null; // collision: can't disambiguate
|
||||
} else {
|
||||
repeaterByHash[c.publicKey[0]] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final result = <_GuessedLocation>[];
|
||||
|
||||
for (final contact in allContacts) {
|
||||
if (contact.hasLocation) continue;
|
||||
|
||||
final anchorSet = <LatLng>{};
|
||||
|
||||
// Collect the contact-side (last-hop) repeater from every known path.
|
||||
// path = [device-side hop, ..., contact-side hop]
|
||||
// Only path.last is actually within radio range of the contact — using
|
||||
// earlier bytes would anchor against our own side of the network.
|
||||
final pathSets = <List<int>>[
|
||||
contact.path.toList(),
|
||||
...pathHistory
|
||||
.getRecentPaths(contact.publicKeyHex)
|
||||
.map((r) => r.pathBytes),
|
||||
];
|
||||
final lastHopBytes = <int>{};
|
||||
for (final pathBytes in pathSets) {
|
||||
if (pathBytes.isEmpty) continue;
|
||||
final lastHop = pathBytes.last;
|
||||
lastHopBytes.add(lastHop);
|
||||
final r = repeaterByHash[lastHop];
|
||||
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
||||
}
|
||||
|
||||
// Fallback: for any last-hop byte with no GPS repeater, average the
|
||||
// positions of contacts with known GPS that share the same last hop.
|
||||
// Those contacts are all adjacent to the same unknown repeater, so their
|
||||
// centroid is a reasonable proxy for its location.
|
||||
for (final byte in lastHopBytes) {
|
||||
if (repeaterByHash.containsKey(byte)) continue;
|
||||
for (final c in withLocation) {
|
||||
if (c.path.isNotEmpty && c.path.last == byte) {
|
||||
anchorSet.add(LatLng(c.latitude!, c.longitude!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter anchors that are geometrically inconsistent with radio range.
|
||||
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
||||
// range of the same node, so isolated outliers are removed.
|
||||
final anchors = maxRangeKm != null && anchorSet.length > 1
|
||||
? _filterConsistentAnchors(anchorSet.toList(), maxRangeKm)
|
||||
: anchorSet.toList();
|
||||
|
||||
if (anchors.isEmpty) continue;
|
||||
|
||||
final LatLng position;
|
||||
if (anchors.length == 1) {
|
||||
// Offset single-anchor guesses so they don't overlap the repeater marker.
|
||||
// Use the contact's public key byte as a deterministic angle seed.
|
||||
const offsetDeg = 0.003; // ~330 m at the equator
|
||||
final angle = (contact.publicKey[1] / 255.0) * 2 * pi;
|
||||
position = LatLng(
|
||||
anchors[0].latitude + offsetDeg * cos(angle),
|
||||
anchors[0].longitude + offsetDeg * sin(angle),
|
||||
);
|
||||
} else {
|
||||
double lat = 0, lon = 0;
|
||||
for (final a in anchors) {
|
||||
lat += a.latitude;
|
||||
lon += a.longitude;
|
||||
}
|
||||
position = LatLng(lat / anchors.length, lon / anchors.length);
|
||||
}
|
||||
result.add(
|
||||
_GuessedLocation(
|
||||
contact: contact,
|
||||
position: position,
|
||||
highConfidence: anchors.length >= 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Estimates the free-space maximum LoRa range in km from the connected
|
||||
/// device's current radio parameters. Returns null if parameters are unknown.
|
||||
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
||||
final freqHz = connector.currentFreqHz;
|
||||
final bwHz = connector.currentBwHz;
|
||||
final sf = connector.currentSf;
|
||||
final txPower = connector.currentTxPower;
|
||||
if (freqHz == null || bwHz == null || sf == null || txPower == null) {
|
||||
return null;
|
||||
}
|
||||
// LoRa receiver sensitivity = thermal noise + NF + required demod SNR
|
||||
const noiseFigureDb = 6.0;
|
||||
final thermalNoiseDbm = -174.0 + 10 * log(bwHz.toDouble()) / ln10;
|
||||
final sensitivityDbm =
|
||||
thermalNoiseDbm + noiseFigureDb + _sfToRequiredSnrDb(sf);
|
||||
// FSPL at max range equals link budget:
|
||||
// FSPL = 20*log10(d_m) + 20*log10(f_hz) - 147.55
|
||||
final linkBudgetDb = txPower.toDouble() - sensitivityDbm;
|
||||
final exponent =
|
||||
(linkBudgetDb + 147.55 - 20 * log(freqHz.toDouble()) / ln10) / 20;
|
||||
return pow(10, exponent) / 1000;
|
||||
}
|
||||
|
||||
double _sfToRequiredSnrDb(int sf) {
|
||||
switch (sf) {
|
||||
case 5:
|
||||
return -2.5;
|
||||
case 6:
|
||||
return -5.0;
|
||||
case 7:
|
||||
return -7.5;
|
||||
case 8:
|
||||
return -10.0;
|
||||
case 9:
|
||||
return -12.5;
|
||||
case 10:
|
||||
return -15.0;
|
||||
case 11:
|
||||
return -17.5;
|
||||
case 12:
|
||||
return -20.0;
|
||||
default:
|
||||
return -10.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes anchors that have no neighbour within 2 * maxRangeKm.
|
||||
/// A node cannot be simultaneously in radio range of two points farther apart
|
||||
/// than twice the expected maximum range.
|
||||
List<LatLng> _filterConsistentAnchors(
|
||||
List<LatLng> anchors,
|
||||
double maxRangeKm,
|
||||
) {
|
||||
const distance = Distance();
|
||||
final maxDistM = maxRangeKm * 2000;
|
||||
return anchors
|
||||
.where((a) => anchors.any((b) => b != a && distance(a, b) <= maxDistM))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Marker _buildGuessedMarker(_GuessedLocation guess) {
|
||||
final color = _getNodeColor(guess.contact.type);
|
||||
return Marker(
|
||||
point: guess.position,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showNodeInfo(
|
||||
context,
|
||||
guess.contact,
|
||||
guessedPosition: guess.position,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: guess.highConfidence ? 0.55 : 0.30),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.not_listed_location,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildMarkers(
|
||||
List<Contact> contacts,
|
||||
settings, {
|
||||
@@ -657,6 +891,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
List<Contact> contactsWithLocation,
|
||||
settings,
|
||||
int markerCount,
|
||||
int guessedCount,
|
||||
) {
|
||||
int nodeCount = 0;
|
||||
for (final contact in contactsWithLocation) {
|
||||
@@ -696,7 +931,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.map_nodesCount(nodeCount),
|
||||
context.l10n.map_nodesCount(
|
||||
nodeCount +
|
||||
(settings.mapShowGuessedLocations
|
||||
? guessedCount
|
||||
: 0),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
@@ -764,6 +1004,12 @@ class _MapScreenState extends State<MapScreen> {
|
||||
context.l10n.map_pinPublic,
|
||||
Colors.orange,
|
||||
),
|
||||
if (settings.mapShowGuessedLocations && guessedCount > 0)
|
||||
_buildLegendItem(
|
||||
Icons.not_listed_location,
|
||||
context.l10n.map_guessedLocation,
|
||||
Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -952,7 +1198,11 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showNodeInfo(BuildContext context, Contact contact) {
|
||||
void _showNodeInfo(
|
||||
BuildContext context,
|
||||
Contact contact, {
|
||||
LatLng? guessedPosition,
|
||||
}) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
@@ -972,10 +1222,16 @@ class _MapScreenState extends State<MapScreen> {
|
||||
children: [
|
||||
_buildInfoRow('Type', contact.typeLabel),
|
||||
_buildInfoRow('Path', contact.pathLabel),
|
||||
_buildInfoRow(
|
||||
'Location',
|
||||
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
_buildInfoRow(
|
||||
'Location',
|
||||
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
||||
)
|
||||
else if (guessedPosition != null)
|
||||
_buildInfoRow(
|
||||
'Est. Location',
|
||||
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
|
||||
),
|
||||
_buildInfoRow(
|
||||
context.l10n.map_lastSeen,
|
||||
_formatLastSeen(contact.lastSeen),
|
||||
@@ -1481,6 +1737,14 @@ class _MapScreenState extends State<MapScreen> {
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text(context.l10n.map_showGuessedLocations),
|
||||
value: settings.mapShowGuessedLocations,
|
||||
onChanged: (value) {
|
||||
service.setMapShowGuessedLocations(value ?? true);
|
||||
},
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.map_keyPrefix,
|
||||
@@ -1744,6 +2008,18 @@ class _MapScreenState extends State<MapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _GuessedLocation {
|
||||
final Contact contact;
|
||||
final LatLng position;
|
||||
final bool highConfidence;
|
||||
|
||||
_GuessedLocation({
|
||||
required this.contact,
|
||||
required this.position,
|
||||
required this.highConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
class _MarkerPayload {
|
||||
final LatLng position;
|
||||
final String label;
|
||||
|
||||
+135
-12
@@ -54,6 +54,7 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
final int? repeaterId;
|
||||
final bool flipPathRound;
|
||||
final bool reversePathRound;
|
||||
final Contact? targetContact;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
@@ -62,6 +63,7 @@ class PathTraceMapScreen extends StatefulWidget {
|
||||
this.repeaterId,
|
||||
this.flipPathRound = false,
|
||||
this.reversePathRound = false,
|
||||
this.targetContact,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -78,6 +80,11 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
PathTraceData? _traceData;
|
||||
// Inferred positions for hops that have no GPS location, keyed by hop byte.
|
||||
Map<int, LatLng> _inferredHopPositions = {};
|
||||
// Endpoint position for the target contact (GPS or guessed).
|
||||
LatLng? _targetContactPosition;
|
||||
bool _targetContactIsGuessed = false;
|
||||
List<LatLng> _points = <LatLng>[];
|
||||
List<Polyline> _polylines = [];
|
||||
LatLng? _initialCenter = LatLng(0, 0);
|
||||
@@ -242,25 +249,91 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
}
|
||||
});
|
||||
|
||||
// For hops with no GPS contact, infer position from other contacts
|
||||
// with known GPS that share the same last-hop byte.
|
||||
final Map<int, LatLng> inferredPositions = {};
|
||||
for (final hop in pathData) {
|
||||
final contact = pathContacts[hop];
|
||||
if (contact != null && contact.hasLocation) continue;
|
||||
final peers = connector.contacts
|
||||
.where(
|
||||
(c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop,
|
||||
)
|
||||
.toList();
|
||||
if (peers.isNotEmpty) {
|
||||
final lat =
|
||||
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
final lon =
|
||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
inferredPositions[hop] = LatLng(lat, lon);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = true;
|
||||
_inferredHopPositions = inferredPositions;
|
||||
_traceData = PathTraceData(
|
||||
pathData: pathData,
|
||||
snrData: snrData,
|
||||
pathContacts: pathContacts,
|
||||
);
|
||||
// Compute endpoint position for the target contact.
|
||||
LatLng? targetPos;
|
||||
bool targetGuessed = false;
|
||||
final target = widget.targetContact;
|
||||
if (target != null) {
|
||||
if (target.hasLocation) {
|
||||
targetPos = LatLng(target.latitude!, target.longitude!);
|
||||
} else if (pathData.isNotEmpty) {
|
||||
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||
// For a round-trip path (flipPathRound), the target-side hop sits
|
||||
// in the middle of the symmetric sequence; .last is the local side.
|
||||
final lastHop = (widget.flipPathRound && pathData.length > 1)
|
||||
? pathData[(pathData.length - 1) ~/ 2]
|
||||
: pathData.last;
|
||||
final peers = connector.contacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.hasLocation &&
|
||||
c.path.isNotEmpty &&
|
||||
c.path.last == lastHop,
|
||||
)
|
||||
.toList();
|
||||
if (peers.isNotEmpty) {
|
||||
final lat =
|
||||
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
final lon =
|
||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (target.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_targetContactPosition = targetPos;
|
||||
_targetContactIsGuessed = targetGuessed;
|
||||
|
||||
_points = <LatLng>[];
|
||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
for (final hop in _traceData!.pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact != null &&
|
||||
contact.hasLocation &&
|
||||
contact.latitude != null &&
|
||||
contact.longitude != null) {
|
||||
if (contact != null && contact.hasLocation) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
} else {
|
||||
final inferred = inferredPositions[hop];
|
||||
if (inferred != null) _points.add(inferred);
|
||||
}
|
||||
}
|
||||
if (targetPos != null) _points.add(targetPos);
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
@@ -382,8 +455,13 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
final markers = <Marker>[];
|
||||
for (final hop in pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact == null || !contact.hasLocation) continue;
|
||||
final point = LatLng(contact.latitude!, contact.longitude!);
|
||||
final inferred = _inferredHopPositions[hop];
|
||||
final hasGps = contact != null && contact.hasLocation;
|
||||
if (!hasGps && inferred == null) continue;
|
||||
final point = hasGps
|
||||
? LatLng(contact.latitude!, contact.longitude!)
|
||||
: inferred!;
|
||||
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
markers.add(
|
||||
Marker(
|
||||
point: point,
|
||||
@@ -392,7 +470,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
color: hasGps
|
||||
? Colors.green
|
||||
: Colors.orange.withValues(alpha: 0.75),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
@@ -405,10 +485,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
contact.publicKey
|
||||
.sublist(0, 1)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(),
|
||||
hasGps ? label : '~$label',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -419,7 +496,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(_buildNodeLabelMarker(point: point, label: contact.name));
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: point,
|
||||
label: contact?.name ?? '~$label',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +550,47 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Add target contact endpoint marker.
|
||||
final targetPos = _targetContactPosition;
|
||||
if (targetPos != null) {
|
||||
final isGuessed = _targetContactIsGuessed;
|
||||
final targetName = widget.targetContact?.name ?? '?';
|
||||
markers.add(
|
||||
Marker(
|
||||
point: targetPos,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: isGuessed
|
||||
? Colors.purple.withValues(alpha: 0.55)
|
||||
: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.person, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: targetPos,
|
||||
label: isGuessed ? '~$targetName' : targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/radio_settings.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import 'app_settings_screen.dart';
|
||||
import 'app_debug_log_screen.dart';
|
||||
import 'ble_debug_log_screen.dart';
|
||||
@@ -43,8 +43,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final l10n = context.l10n;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AdaptiveAppBarTitle(l10n.settings_title),
|
||||
centerTitle: true,
|
||||
title: AppBarTitle(
|
||||
l10n.settings_title,
|
||||
indicators: false,
|
||||
subtitle: false,
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
@@ -274,6 +277,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onTap: () => _editLocation(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.group_add_outlined),
|
||||
title: Text(l10n.settings_contactSettings),
|
||||
subtitle: Text(l10n.settings_contactSettingsSubtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _editAutoAddConfig(context, connector),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.visibility_off_outlined),
|
||||
title: Text(l10n.settings_privacyMode),
|
||||
@@ -849,6 +860,121 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editAutoAddConfig(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
bool autoAddChat = false;
|
||||
bool autoAddRepeater = false;
|
||||
bool autoAddRoomServer = false;
|
||||
bool autoAddSensor = false;
|
||||
bool overwriteOldest = false;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
autoAddChat = connector.autoAddUsers ?? false;
|
||||
autoAddRepeater = connector.autoAddRepeaters ?? false;
|
||||
autoAddRoomServer = connector.autoAddRoomServers ?? false;
|
||||
autoAddSensor = connector.autoAddSensors ?? false;
|
||||
overwriteOldest = connector.autoAddOverwriteOldest ?? false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(l10n.contactsSettings_autoAddTitle),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddUsersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddUsersSubtitle,
|
||||
value: autoAddChat,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddChat = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddRepeatersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddRepeatersSubtitle,
|
||||
value: autoAddRepeater,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddRepeater = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddRoomServersTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddRoomServersSubtitle,
|
||||
value: autoAddRoomServer,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddRoomServer = value);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_autoAddSensorsTitle,
|
||||
subtitle: l10n.contactsSettings_autoAddSensorsSubtitle,
|
||||
value: autoAddSensor,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => autoAddSensor = value);
|
||||
},
|
||||
),
|
||||
Divider(height: 4),
|
||||
FeatureToggleRow(
|
||||
title: l10n.contactsSettings_overwriteOldestTitle,
|
||||
subtitle: l10n.contactsSettings_overwriteOldestSubtitle,
|
||||
value: overwriteOldest,
|
||||
onChanged: (value) {
|
||||
setDialogState(() => overwriteOldest = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_sendSettings(
|
||||
connector,
|
||||
autoAddChat,
|
||||
autoAddRepeater,
|
||||
autoAddRoomServer,
|
||||
autoAddSensor,
|
||||
overwriteOldest,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(l10n.common_save),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendSettings(
|
||||
MeshCoreConnector connector,
|
||||
bool autoAddChat,
|
||||
bool autoAddRepeater,
|
||||
bool autoAddRoomServer,
|
||||
bool autoAddSensor,
|
||||
bool overwriteOldest,
|
||||
) async {
|
||||
final frame = buildSetAutoAddConfigFrame(
|
||||
autoAddChat: autoAddChat,
|
||||
autoAddRepeater: autoAddRepeater,
|
||||
autoAddRoomServer: autoAddRoomServer,
|
||||
autoAddSensor: autoAddSensor,
|
||||
overwriteOldest: overwriteOldest,
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
await connector.sendFrame(buildGetAutoAddFlagsFrame());
|
||||
}
|
||||
}
|
||||
|
||||
class _RadioSettingsDialog extends StatefulWidget {
|
||||
|
||||
Reference in New Issue
Block a user