Refactor code structure and remove redundant sections for improved readability and maintainability

This commit is contained in:
zjs81
2026-06-12 22:55:41 -07:00
parent 51d6210920
commit 3707acb124
34 changed files with 1008 additions and 84 deletions
+32 -2
View File
@@ -328,6 +328,20 @@ class MeshCoreConnector extends ChangeNotifier {
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
/// Stable per-radio key for transport-agnostic per-device settings such as
/// battery chemistry. On BLE this is the existing remoteId (so previously
/// saved settings are preserved); on USB/TCP — where there is no BLE
/// remoteId — it falls back to the node's public key, which identifies the
/// same physical radio across transports. Null until a device identity is
/// known.
String? get batteryDeviceKey {
if (_deviceId != null) return _deviceId;
if (_selfPublicKey != null && _selfPublicKey!.isNotEmpty) {
return selfPublicKeyHex;
}
return null;
}
MeshCoreTransportType get activeTransport => _activeTransport;
String? get activeUsbPort => _usbManager.activePortKey;
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
@@ -493,7 +507,7 @@ class MeshCoreConnector extends ChangeNotifier {
}
String _batteryChemistryForDevice() {
final deviceId = _device?.remoteId.toString();
final deviceId = batteryDeviceKey;
if (deviceId == null || _appSettingsService == null) return 'nmc';
return _appSettingsService!.batteryChemistryForDevice(deviceId);
}
@@ -1607,6 +1621,20 @@ class MeshCoreConnector extends ChangeNotifier {
await stopScan();
}
await Future<void>.delayed(const Duration(milliseconds: 200));
// The read pump can fail the instant the port opens (e.g. a device that
// re-enumerates on open). That error is emitted on a broadcast stream
// before the listener below attaches, so it would otherwise be lost and
// the connect would stall until the SELF_INFO timeout. Check transport
// liveness directly and abort fast with the real cause.
if (!_usbManager.isConnected) {
final cause = _usbManager.lastError;
throw StateError(
'USB device disconnected during connect'
'${cause == null ? '' : ': $cause'}',
);
}
_usbFrameSubscription = _usbManager.frameStream.listen(
_handleFrame,
onError: (error, stackTrace) {
@@ -5737,7 +5765,9 @@ class MeshCoreConnector extends ChangeNotifier {
) {
if (!isRoomServer) return null;
if (!msg.isOutgoing) {
final senderContact = _contacts.cast<Contact?>().firstWhere(
// Saved contacts first, then discovery-only nodes, so reaction matching
// resolves the author's name even when they haven't been saved.
final senderContact = allContactsUnfiltered.cast<Contact?>().firstWhere(
(c) =>
c != null &&
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
String? get activePortKey => _activePortKey;
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
bool get isConnected => _service.isConnected;
Object? get lastError => _service.lastError;
Stream<Uint8List> get frameStream => _service.frameStream;
// --- Configuration ---
+1
View File
@@ -143,6 +143,7 @@
"scanner_chromeRequired": "Chrome Browser Required",
"scanner_chromeRequiredMessage": "This web application requires Google Chrome or a Chromium-based browser for Bluetooth support.",
"scanner_enableBluetooth": "Enable Bluetooth",
"scanner_bluetoothWebUnsupported": "Bluetooth isn't available in the browser. Connect over USB instead.",
"device_quickSwitch": "Quick switch",
"device_meshcore": "MeshCore",
"settings_title": "Settings",
+6
View File
@@ -694,6 +694,12 @@ abstract class AppLocalizations {
/// **'Enable Bluetooth'**
String get scanner_enableBluetooth;
/// No description provided for @scanner_bluetoothWebUnsupported.
///
/// In en, this message translates to:
/// **'Bluetooth isn\'t available in the browser. Connect over USB instead.'**
String get scanner_bluetoothWebUnsupported;
/// No description provided for @device_quickSwitch.
///
/// In en, this message translates to:
+4
View File
@@ -318,6 +318,10 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Активирайте Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Бързо превключване';
+4
View File
@@ -321,6 +321,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Bluetooth aktivieren';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Schnelles Umschalten';
+4
View File
@@ -315,6 +315,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Enable Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Quick switch';
+4
View File
@@ -319,6 +319,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Habilitar Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Cambiar rápidamente';
+4
View File
@@ -321,6 +321,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Activer le Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Basculement rapide';
+4
View File
@@ -318,6 +318,10 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Engedje be a Bluetooth funkciót';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Gyors váltás';
+4
View File
@@ -321,6 +321,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Abilita il Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Passa velocemente';
+4
View File
@@ -308,6 +308,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Bluetoothを有効にする';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => '素早い切り替え';
+4
View File
@@ -307,6 +307,10 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get scanner_enableBluetooth => '블루투스 활성화';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => '빠른 전환';
+4
View File
@@ -317,6 +317,10 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Activeer Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Snelle overschakeling';
+4
View File
@@ -322,6 +322,10 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Włącz Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Szybka zmiana';
+4
View File
@@ -320,6 +320,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Ative o Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Mudar rapidamente';
+4
View File
@@ -320,6 +320,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Включите Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Быстрое переключение';
+4
View File
@@ -319,6 +319,10 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Povolte Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Rýchle prepínač';
+4
View File
@@ -318,6 +318,10 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Omogočite Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Hitro preklop';
+4
View File
@@ -316,6 +316,10 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Aktivera Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Snabb växling';
+4
View File
@@ -319,6 +319,10 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get scanner_enableBluetooth => 'Увімкніть Bluetooth';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => 'Швидке перемикання';
+4
View File
@@ -305,6 +305,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get scanner_enableBluetooth => '启用蓝牙';
@override
String get scanner_bluetoothWebUnsupported =>
'Bluetooth isn\'t available in the browser. Connect over USB instead.';
@override
String get device_quickSwitch => '快速切换';
+4 -4
View File
@@ -141,7 +141,7 @@ class AppSettings {
this.mapKeyPrefix = '',
this.mapShowMarkers = true,
this.mapShowGuessedLocations = true,
this.enableMessageTracing = false,
this.enableMessageTracing = true,
this.mapCacheBounds,
this.mapCacheMinZoom = 10,
this.mapCacheMaxZoom = 15,
@@ -149,7 +149,7 @@ class AppSettings {
this.notifyOnNewMessage = true,
this.notifyOnNewChannelMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.autoRouteRotationEnabled = true,
this.maxRouteWeight = 5.0,
this.initialRouteWeight = 3.0,
this.routeWeightSuccessIncrement = 0.5,
@@ -264,7 +264,7 @@ class AppSettings {
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapShowGuessedLocations:
json['map_show_guessed_locations'] as bool? ?? true,
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false,
enableMessageTracing: json['enable_message_tracing'] as bool? ?? true,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
@@ -276,7 +276,7 @@ class AppSettings {
json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
json['auto_route_rotation_enabled'] as bool? ?? true,
maxRouteWeight: (json['max_route_weight'] as num?)?.toDouble() ?? 5.0,
initialRouteWeight:
(json['initial_route_weight'] as num?)?.toDouble() ?? 3.0,
+1 -1
View File
@@ -607,7 +607,7 @@ class AppSettingsScreen extends StatelessWidget {
AppSettingsService settingsService,
MeshCoreConnector connector,
) {
final deviceId = connector.deviceId;
final deviceId = connector.batteryDeviceKey;
final isConnected = connector.isConnected && deviceId != null;
final selection = isConnected
? settingsService.batteryChemistryForDevice(deviceId)
+24 -10
View File
@@ -381,12 +381,14 @@ class _ChatScreenState extends State<ChatScreen> {
}
final messageIndex = index;
Contact contact = _resolveContact(connector);
final bool isRoom = contact.type == advTypeRoom;
final message = reversedMessages[messageIndex];
String fourByteHex = '';
if (contact.type == advTypeRoom) {
Contact? roomAuthor;
if (isRoom) {
// Room-server messages carry the original author's 4-byte prefix
// separately from message.text; use it only for resolving the name.
contact = _resolveContactFrom4Bytes(
roomAuthor = _resolveContactFrom4Bytes(
connector,
message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
@@ -396,6 +398,9 @@ class _ChatScreenState extends State<ChatScreen> {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
// Only adopt the author identity when we actually know them; never
// fall back to the room server's own name as the sender.
if (roomAuthor != null) contact = roomAuthor;
}
return Builder(
@@ -403,11 +408,12 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale,
);
final resolvedContact = _resolveContact(connector);
final bubble = _MessageBubble(
message: message,
senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
senderName: isRoom
? (roomAuthor != null
? "${roomAuthor.name} [$fourByteHex]"
: "[$fourByteHex]")
: contact.name,
sourceId: widget.contact.publicKeyHex,
textScale: textScale,
@@ -755,13 +761,17 @@ class _ChatScreenState extends State<ChatScreen> {
return connector.contacts[_resolveContactIndex];
}
Contact _resolveContactFrom4Bytes(
Contact? _resolveContactFrom4Bytes(
MeshCoreConnector connector,
Uint8List key4Bytes,
) {
return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact,
// Match against saved contacts first, then nodes only seen via discovery
// a room poster you haven't saved may still be in the discovered list.
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
(c) =>
c != null &&
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => null,
);
}
@@ -1049,7 +1059,11 @@ class _ChatScreenState extends State<ChatScreen> {
if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me;
} else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]";
// An unresolved author leaves `contact` as the room server itself; show
// only the prefix rather than mislabeling the post with the room's name.
senderName = contact.type == advTypeRoom
? "[$fourByteHex]"
: "${contact.name} [$fourByteHex]";
} else {
senderName = _resolveContact(connector).name;
}
+358 -40
View File
@@ -1,4 +1,3 @@
import 'dart:collection';
import 'dart:math';
import 'package:flutter/foundation.dart';
@@ -94,6 +93,11 @@ class _MapScreenState extends State<MapScreen> {
String _searchQuery = '';
List<_GuessedLocation> _cachedGuessedLocations = [];
String _guessedLocationsCacheKey = '';
int? _sharedMarkersCacheSignature;
Locale? _sharedMarkersCacheLocale;
List<_SharedMarker> _cachedSharedMarkers = const [];
_NodeMarkersCacheKey? _nodeMarkersCacheKey;
List<Marker> _cachedNodeMarkers = const [];
@override
void dispose() {
@@ -283,11 +287,23 @@ class _MapScreenState extends State<MapScreen> {
@override
Widget build(BuildContext context) {
return Consumer3<MeshCoreConnector, AppSettingsService, PathHistoryService>(
builder: (context, connector, settingsService, pathHistory, child) {
return Builder(
builder: (context) {
final connectorSnapshot = context
.select<MeshCoreConnector, _MapConnectorSnapshot>(
_MapConnectorSnapshot.fromConnector,
);
final connector = connectorSnapshot.connector;
final settings = context.select<AppSettingsService, AppSettings>(
(service) => service.settings,
);
final pathHistoryVersion = context.select<PathHistoryService, int>(
(service) => service.version,
);
final settingsService = context.read<AppSettingsService>();
final pathHistory = context.read<PathHistoryService>();
final tileCache = context.read<MapTileCacheService>();
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final settings = settingsService.settings;
final allContacts = connector.allContacts;
final contacts = settings.mapShowDiscoveryContacts
@@ -296,7 +312,10 @@ class _MapScreenState extends State<MapScreen> {
final highlightPosition = widget.highlightPosition;
final sharedMarkers = settings.mapShowMarkers
? _collectSharedMarkers(connector)
? _collectSharedMarkers(
connector,
connectorSnapshot.markerSignature,
)
.where(
(marker) =>
!_hiddenMarkerIds.contains(marker.id) &&
@@ -347,9 +366,17 @@ class _MapScreenState extends State<MapScreen> {
.where((c) => c.hasLocation)
.toList();
// Guessed markers represent the same node types as known-location
// markers, so apply the node-type filters before estimating positions.
final guessCandidates = _filterContactsBySettings(
filteredByKeyPrefix,
settings,
noLocations: true,
);
// Compute guessed locations with caching
final maxRangeKm = _estimateLoRaRangeKm(connector);
final filteredKeys = filteredByKeyPrefix
final filteredKeys = guessCandidates
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
.join(',');
final anchorKeys = allContactsWithLocation
@@ -359,12 +386,12 @@ class _MapScreenState extends State<MapScreen> {
)
.join(',');
final cacheKey =
'$filteredKeys|$anchorKeys|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
'$filteredKeys|$anchorKeys|$pathHistoryVersion:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
if (cacheKey != _guessedLocationsCacheKey) {
_guessedLocationsCacheKey = cacheKey;
_cachedGuessedLocations = settings.mapShowGuessedLocations
? _computeGuessedLocations(
filteredByKeyPrefix,
guessCandidates,
allContactsWithLocation,
pathHistory,
maxRangeKm,
@@ -759,9 +786,21 @@ class _MapScreenState extends State<MapScreen> {
guessedLocations,
showLabels: _showNodeLabels,
),
..._buildNodeMarkers(
..._buildNodeMarkersCached(
visibleContacts,
settings,
connectorSnapshot.contactsSignature,
connectorSnapshot.batterySignature,
_freshness,
settings.mapTimeFilterHours,
settings.mapKeyPrefixEnabled,
settings.mapKeyPrefix,
settings.mapShowDiscoveryContacts,
Object.hashAllUnordered(
settings.batteryChemistryByRepeaterId.entries.map(
(entry) => Object.hash(entry.key, entry.value),
),
),
showLabels: _showNodeLabels,
selectedContact: selectedContact,
),
@@ -873,6 +912,59 @@ class _MapScreenState extends State<MapScreen> {
);
}
List<Marker> _buildNodeMarkersCached(
List<Contact> contacts,
AppSettings settings,
int contactsSignature,
int batterySignature,
_Freshness freshness,
double timeFilterHours,
bool keyPrefixEnabled,
String keyPrefix,
bool showDiscoveryContacts,
int batteryChemistrySignature, {
required bool showLabels,
Contact? selectedContact,
}) {
final visibleContactsSignature = Object.hashAll(
contacts.map(
(contact) =>
Object.hash(_mapContactSignature(contact), _ageOf(contact)),
),
);
final key = _NodeMarkersCacheKey(
contactsSignature: contactsSignature,
visibleContactsSignature: visibleContactsSignature,
batterySignature: batterySignature,
freshness: freshness,
timeFilterHours: timeFilterHours,
keyPrefixEnabled: keyPrefixEnabled,
keyPrefix: keyPrefix,
showDiscoveryContacts: showDiscoveryContacts,
batteryChemistrySignature: batteryChemistrySignature,
showLabels: showLabels,
selectedKey: selectedContact?.publicKeyHex,
zoom: _zoom,
overlapsMode: settings.mapShowOverlaps,
showRepeaters: settings.mapShowRepeaters,
showChatNodes: settings.mapShowChatNodes,
showOtherNodes: settings.mapShowOtherNodes,
isBuildingPathTrace: _isBuildingPathTrace,
);
if (key != _nodeMarkersCacheKey) {
_nodeMarkersCacheKey = key;
_cachedNodeMarkers = List.unmodifiable(
_buildNodeMarkers(
contacts,
settings,
showLabels: showLabels,
selectedContact: selectedContact,
),
);
}
return _cachedNodeMarkers;
}
List<_GuessedLocation> _computeGuessedLocations(
List<Contact> allContacts,
List<Contact> withLocation,
@@ -1146,17 +1238,18 @@ class _MapScreenState extends State<MapScreen> {
}) {
List<Contact> filtered = [];
bool addContact = false;
for (final contact in contacts) {
addContact = false;
if (!contact.hasLocation && !noLocations) {
continue;
}
// Apply node type filters
// Apply node type filters. The overlaps toggle is purely a visual
// highlight (applied in _buildNodeMarkers) and no longer affects which
// nodes are shown.
if (contact.type == advTypeRepeater &&
(settings.mapShowRepeaters ||
_isBuildingPathTrace ||
settings.mapShowOverlaps)) {
(settings.mapShowRepeaters || _isBuildingPathTrace)) {
addContact = true;
}
if (contact.type == advTypeChat &&
@@ -1165,9 +1258,7 @@ class _MapScreenState extends State<MapScreen> {
}
if (contact.type != advTypeChat &&
contact.type != advTypeRepeater &&
(settings.mapShowOtherNodes ||
_isBuildingPathTrace ||
settings.mapShowOverlaps)) {
(settings.mapShowOtherNodes || _isBuildingPathTrace)) {
addContact = true;
}
@@ -1175,25 +1266,6 @@ class _MapScreenState extends State<MapScreen> {
addContact = false;
}
if (settings.mapShowOverlaps) {
final hasOverlap = contacts
.where(
(c) =>
c.publicKeyHex != contact.publicKeyHex &&
c.publicKey.first == contact.publicKey.first &&
(c.type == advTypeRepeater || c.type == advTypeRoom) &&
(contact.type == advTypeRepeater ||
contact.type == advTypeRoom),
)
.firstOrNull;
if (hasOverlap == null &&
settings.mapShowOverlaps &&
!_isBuildingPathTrace) {
addContact = false;
}
}
if (addContact) {
filtered.add(contact);
}
@@ -1212,13 +1284,34 @@ class _MapScreenState extends State<MapScreen> {
final selectedKey = selectedContact?.publicKeyHex;
final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList();
// Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms
// whose first key byte collides with another repeater/room on the map.
final overlapPrefixes = <int>{};
if (overlapsMode) {
final counts = <int, int>{};
for (final contact in contacts) {
if (contact.type == advTypeRepeater || contact.type == advTypeRoom) {
final prefix = contact.publicKey.first;
counts[prefix] = (counts[prefix] ?? 0) + 1;
}
}
counts.forEach((prefix, count) {
if (count > 1) overlapPrefixes.add(prefix);
});
}
bool isOverlap(Contact contact) =>
overlapsMode &&
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
overlapPrefixes.contains(contact.publicKey.first);
void addNode(Contact contact, {bool dot = false}) {
markers.add(_nodeMarker(contact, overlapsMode: overlapsMode, dot: dot));
final overlap = isOverlap(contact);
markers.add(_nodeMarker(contact, overlapsMode: overlap, dot: dot));
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: LatLng(contact.latitude!, contact.longitude!),
label: overlapsMode
label: overlap
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
: contact.name,
),
@@ -1255,7 +1348,7 @@ class _MapScreenState extends State<MapScreen> {
markers.add(
_nodeMarker(
selectedContact,
overlapsMode: overlapsMode,
overlapsMode: isOverlap(selectedContact),
selected: true,
),
);
@@ -2371,7 +2464,16 @@ class _MapScreenState extends State<MapScreen> {
}
}
List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) {
List<_SharedMarker> _collectSharedMarkers(
MeshCoreConnector connector,
int markerSignature,
) {
final locale = Localizations.localeOf(context);
if (_sharedMarkersCacheSignature == markerSignature &&
_sharedMarkersCacheLocale == locale) {
return _cachedSharedMarkers;
}
// Build a _SharedMarker per message (history empty), grouped by dedupe key.
// Afterwards pick the latest per key and fill its history from older ones.
final updatesByKey = <String, List<_SharedMarker>>{};
@@ -2463,7 +2565,10 @@ class _MapScreenState extends State<MapScreen> {
});
markers.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return markers;
_sharedMarkersCacheSignature = markerSignature;
_sharedMarkersCacheLocale = locale;
_cachedSharedMarkers = List.unmodifiable(markers);
return _cachedSharedMarkers;
}
Marker _buildSharedMarker(_SharedMarker marker) {
@@ -3564,6 +3669,219 @@ enum _NodeAge { online, recent, stale }
enum _Freshness { all, online, recent, stale }
int _bytesSignature(Iterable<int>? bytes) {
if (bytes == null) return 0;
return Object.hashAll(bytes);
}
int _mapContactSignature(Contact contact) {
return Object.hash(
contact.publicKeyHex,
contact.name,
contact.type,
contact.flags,
contact.pathLength,
_bytesSignature(contact.path),
contact.pathOverride,
_bytesSignature(contact.pathOverrideBytes),
contact.latitude,
contact.longitude,
contact.lastSeen.millisecondsSinceEpoch,
contact.lastMessageAt.millisecondsSinceEpoch,
contact.isActive,
contact.wasPulled,
);
}
class _MapConnectorSnapshot {
final MeshCoreConnector connector;
final int contactsSignature;
final int markerSignature;
final int batterySignature;
final int uiSignature;
const _MapConnectorSnapshot({
required this.connector,
required this.contactsSignature,
required this.markerSignature,
required this.batterySignature,
required this.uiSignature,
});
factory _MapConnectorSnapshot.fromConnector(MeshCoreConnector connector) {
final allContacts = connector.allContacts;
final contactsSignature = Object.hashAll(
allContacts.map(_mapContactSignature),
);
final batterySignature = Object.hashAll(
allContacts
.where((contact) => contact.type == advTypeRepeater)
.map(
(contact) => Object.hash(
contact.publicKeyHex,
connector.getRepeaterBatteryMillivolts(contact.publicKeyHex),
),
),
);
final markerParts = <Object?>[connector.selfName];
for (final contact in connector.contacts) {
markerParts.add(contact.publicKeyHex);
markerParts.add(contact.name);
for (final message in connector.getMessages(contact)) {
if (!message.text.trimLeft().startsWith('m:')) continue;
markerParts.add(
Object.hash(
message.messageId,
message.text,
message.timestamp.millisecondsSinceEpoch,
message.isOutgoing,
),
);
}
}
for (final channel in connector.channels) {
markerParts.add(
Object.hash(
channel.index,
channel.name,
channel.isPublicChannel,
channel.isEmpty,
),
);
for (final message in connector.getChannelMessages(channel)) {
if (!message.text.trimLeft().startsWith('m:')) continue;
markerParts.add(
Object.hash(
message.messageId,
message.text,
message.senderName,
message.timestamp.millisecondsSinceEpoch,
),
);
}
}
return _MapConnectorSnapshot(
connector: connector,
contactsSignature: contactsSignature,
markerSignature: Object.hashAll(markerParts),
batterySignature: batterySignature,
uiSignature: Object.hash(
connector.isConnected,
connector.selfLatitude,
connector.selfLongitude,
connector.currentFreqHz,
connector.currentBwHz,
connector.currentSf,
connector.currentTxPower,
connector.getTotalContactsUnreadCount(),
connector.getTotalChannelsUnreadCount(),
),
);
}
@override
bool operator ==(Object other) {
return other is _MapConnectorSnapshot &&
contactsSignature == other.contactsSignature &&
markerSignature == other.markerSignature &&
batterySignature == other.batterySignature &&
uiSignature == other.uiSignature;
}
@override
int get hashCode => Object.hash(
contactsSignature,
markerSignature,
batterySignature,
uiSignature,
);
}
class _NodeMarkersCacheKey {
final int contactsSignature;
final int visibleContactsSignature;
final int batterySignature;
final _Freshness freshness;
final double timeFilterHours;
final bool keyPrefixEnabled;
final String keyPrefix;
final bool showDiscoveryContacts;
final int batteryChemistrySignature;
final bool showLabels;
final String? selectedKey;
final double zoom;
final bool overlapsMode;
final bool showRepeaters;
final bool showChatNodes;
final bool showOtherNodes;
final bool isBuildingPathTrace;
const _NodeMarkersCacheKey({
required this.contactsSignature,
required this.visibleContactsSignature,
required this.batterySignature,
required this.freshness,
required this.timeFilterHours,
required this.keyPrefixEnabled,
required this.keyPrefix,
required this.showDiscoveryContacts,
required this.batteryChemistrySignature,
required this.showLabels,
required this.selectedKey,
required this.zoom,
required this.overlapsMode,
required this.showRepeaters,
required this.showChatNodes,
required this.showOtherNodes,
required this.isBuildingPathTrace,
});
@override
bool operator ==(Object other) {
return other is _NodeMarkersCacheKey &&
contactsSignature == other.contactsSignature &&
visibleContactsSignature == other.visibleContactsSignature &&
batterySignature == other.batterySignature &&
freshness == other.freshness &&
timeFilterHours == other.timeFilterHours &&
keyPrefixEnabled == other.keyPrefixEnabled &&
keyPrefix == other.keyPrefix &&
showDiscoveryContacts == other.showDiscoveryContacts &&
batteryChemistrySignature == other.batteryChemistrySignature &&
showLabels == other.showLabels &&
selectedKey == other.selectedKey &&
zoom == other.zoom &&
overlapsMode == other.overlapsMode &&
showRepeaters == other.showRepeaters &&
showChatNodes == other.showChatNodes &&
showOtherNodes == other.showOtherNodes &&
isBuildingPathTrace == other.isBuildingPathTrace;
}
@override
int get hashCode => Object.hash(
contactsSignature,
visibleContactsSignature,
batterySignature,
freshness,
timeFilterHours,
keyPrefixEnabled,
keyPrefix,
showDiscoveryContacts,
batteryChemistrySignature,
showLabels,
selectedKey,
zoom,
overlapsMode,
showRepeaters,
showChatNodes,
showOtherNodes,
isBuildingPathTrace,
);
}
class _GuessedLocation {
final Contact contact;
final LatLng position;
+9
View File
@@ -207,6 +207,15 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
void _toggleScan(MeshCoreConnector connector) {
if (PlatformInfo.isWeb) {
// flutter_blue_plus has no web backend, so a BLE scan silently no-ops in
// the browser. Tell the user instead of leaving them staring at a button.
showDismissibleSnackBar(
context,
content: Text(context.l10n.scanner_bluetoothWebUnsupported),
);
return;
}
if (connector.state == MeshCoreConnectionState.scanning) {
connector.stopScan();
} else {
+18 -10
View File
@@ -725,18 +725,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.setNodeName(controller.text);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(l10n.settings_nodeNameUpdated),
ListenableBuilder(
listenable: controller,
builder: (context, _) {
final name = controller.text.trim();
return TextButton(
onPressed: name.isEmpty
? null
: () async {
Navigator.pop(context);
await connector.setNodeName(name);
await connector.refreshDeviceInfo();
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: Text(l10n.settings_nodeNameUpdated),
);
},
child: Text(l10n.common_save),
);
},
child: Text(l10n.common_save),
),
],
),
+14
View File
@@ -386,6 +386,10 @@ class _UsbScreenState extends State<UsbScreen> {
void _showError(Object error) {
if (!mounted) return;
// Cancelling the browser's serial port picker is a normal user action, not
// an error don't show a scary red toast (and never leak the raw
// DOMException text).
if (_isUserCancelledPortPicker(error)) return;
showDismissibleSnackBar(
context,
content: Text(_friendlyErrorMessage(error)),
@@ -393,6 +397,16 @@ class _UsbScreenState extends State<UsbScreen> {
);
}
bool _isUserCancelledPortPicker(Object error) {
if (error is StateError &&
error.message.contains('No USB serial device selected')) {
return true;
}
final text = error.toString();
return text.contains('No port selected by the user') ||
text.contains("Failed to execute 'requestPort'");
}
String _friendlyErrorMessage(Object error) {
final l10n = context.l10n;
+38 -6
View File
@@ -114,6 +114,36 @@ class NotificationService {
return _isInitialized;
}
// Cached "are we allowed to post notifications" result. Null = not yet
// determined. Avoids calling _notifications.show() when it would only throw
// "You must request notifications permissions first" (every web build, and
// Android 13+ before the user grants the permission).
bool? _canNotify;
Future<bool> _ensureCanNotify() async {
if (!await _ensureInitialized()) return false;
final cached = _canNotify;
if (cached != null) return cached;
// flutter_local_notifications has no web backend, so show() always throws.
// Skip silently instead of logging an error per incoming message.
if (kIsWeb) return _canNotify = false;
// On Android 13+ notifications require an explicit grant; reflect the real
// OS state so we don't spam failed show() calls when denied.
final androidPlugin = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final enabled = await androidPlugin.areNotificationsEnabled();
return _canNotify = enabled ?? false;
}
// iOS/macOS request permission during initialize(); desktop has no gate.
return _canNotify = true;
}
Future<bool> requestPermissions() async {
if (!_isInitialized) {
await initialize();
@@ -126,7 +156,8 @@ class NotificationService {
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
_canNotify = granted ?? false;
return _canNotify!;
}
// iOS permissions are requested during initialization
@@ -140,7 +171,8 @@ class NotificationService {
badge: true,
sound: true,
);
return granted ?? false;
_canNotify = granted ?? false;
return _canNotify!;
}
return true;
@@ -165,7 +197,7 @@ class NotificationService {
String? contactId,
int? badgeCount,
}) async {
if (!await _ensureInitialized()) return;
if (!await _ensureCanNotify()) return;
final androidDetails = AndroidNotificationDetails(
'messages',
@@ -215,7 +247,7 @@ class NotificationService {
required String contactType,
String? contactId,
}) async {
if (!await _ensureInitialized()) return;
if (!await _ensureCanNotify()) return;
const androidDetails = AndroidNotificationDetails(
'adverts',
@@ -265,7 +297,7 @@ class NotificationService {
int? channelIndex,
int? badgeCount,
}) async {
if (!await _ensureInitialized()) return;
if (!await _ensureCanNotify()) return;
final androidDetails = AndroidNotificationDetails(
'channel_messages',
@@ -545,7 +577,7 @@ class NotificationService {
}
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!await _ensureInitialized()) return;
if (!await _ensureCanNotify()) return;
// Group by type
final messages = batch
@@ -33,12 +33,14 @@ class UsbSerialService {
String? _connectedPortLabel;
FlSerial? _serial;
AppDebugLogService? _debugLogService;
Object? _lastError;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel =>
_connectedPortLabel ?? _connectedPortKey;
Stream<Uint8List> get frameStream => _frameController.stream;
Object? get lastError => _lastError;
bool get _useAndroidUsbHost =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
bool get _useDesktopFlSerial =>
@@ -434,6 +436,7 @@ class UsbSerialService {
}
void _addFrameError(Object error, [StackTrace? stackTrace]) {
_lastError = error;
if (_frameController.isClosed) {
return;
}
+48 -11
View File
@@ -15,6 +15,18 @@ class UsbSerialService {
static const Map<String, String> _knownUsbNames = <String, String>{
'2886:1667': 'Seeed Wio Tracker L1',
};
/// USB-to-UART bridge chips whose hardware auto-reset circuit requires DTR
/// to be held asserted after open (otherwise the MCU resets). Native-USB-CDC
/// boards (nRF52840/Adafruit 0x239A, Espressif native 0x303A, Seeed 0x2886)
/// tie DTR to the bootloader/reset line, so asserting it re-enumerates and
/// drops the device ("The device has been lost"); they must be left alone.
static const Set<int> _uartBridgeVendorIds = <int>{
0x10C4, // Silicon Labs CP210x
0x1A86, // QinHeng CH340 / CH9102
0x0403, // FTDI
0x067B, // Prolific PL2303
};
static final Map<String, String> _deviceNamesByPortKey = <String, String>{};
static final Map<String, String> _baseLabelsByPortKey = <String, String>{};
static final Map<String, JSObject> _authorizedPortsByKey =
@@ -34,12 +46,14 @@ class UsbSerialService {
String _requestPortLabel = 'Choose USB Device';
String _fallbackDeviceName = 'Web Serial Device';
AppDebugLogService? _debugLogService;
Object? _lastError;
UsbSerialStatus get status => _status;
String? get activePortKey => _connectedPortKey;
String? get activePortDisplayLabel => _connectedPortName ?? _connectedPortKey;
Stream<Uint8List> get frameStream => _frameController.stream;
bool get isConnected => _status == UsbSerialStatus.connected;
Object? get lastError => _lastError;
JSObject get _navigator => JSObject.fromInteropObject(web.window.navigator);
bool get _isSupported => _navigator.has('serial');
@@ -74,6 +88,7 @@ class UsbSerialService {
}
_status = UsbSerialStatus.connecting;
_lastError = null;
_frameDecoder.reset();
try {
@@ -282,16 +297,30 @@ class UsbSerialService {
..['flowControl'] = 'none'.toJS;
await port.callMethod<JSPromise<JSAny?>>('open'.toJS, options).toDart;
// Prevent ESP32 USB-CDC reset: hold DTR=true, RTS=false after open.
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
// Only UART-bridge chips (CP210x/CH340/FTDI/PL2303) need DTR held high to
// avoid the auto-reset circuit firing on open. Native-USB-CDC boards
// (e.g. nRF52840/Adafruit) tie DTR to the reset line toggling it there
// re-enumerates the device and Web Serial reports "The device has been
// lost". Leave their signals untouched.
final vendorId = _portInfo(port)?.usbVendorId;
final isUartBridge =
vendorId != null && _uartBridgeVendorIds.contains(vendorId);
_debugLogService?.info(
'Open: vendorId=${vendorId == null ? 'unknown' : '0x${vendorId.toRadixString(16)}'} '
'uartBridge=$isUartBridge (DTR ${isUartBridge ? 'asserted' : 'left default'})',
tag: 'USB Serial',
);
if (isUartBridge) {
try {
final signals = JSObject()
..['dataTerminalReady'] = true.toJS
..['requestToSend'] = false.toJS;
await port
.callMethod<JSPromise<JSAny?>>('setSignals'.toJS, signals)
.toDart;
} catch (_) {
// setSignals may not be supported on all browsers/devices.
}
}
}
@@ -384,13 +413,21 @@ class UsbSerialService {
} catch (error, stackTrace) {
_debugLogService?.error('_pumpReads error: $error', tag: 'USB Serial');
if (_status == UsbSerialStatus.connected) {
// The transport is dead reflect that in status immediately so a
// concurrent connect handshake fails fast instead of waiting for a
// SELF_INFO that can never arrive.
_status = UsbSerialStatus.disconnected;
_lastError = error;
_addFrameError(error, stackTrace);
}
} finally {
_debugLogService?.info('_pumpReads: ended', tag: 'USB Serial');
_releaseLock(reader);
if (_status == UsbSerialStatus.connected && identical(reader, _reader)) {
_addFrameError(StateError('USB serial connection closed'));
_status = UsbSerialStatus.disconnected;
final closedError = StateError('USB serial connection closed');
_lastError = closedError;
_addFrameError(closedError);
}
}
}