Compare commits

..

14 Commits

Author SHA1 Message Date
zjs81 60e8ee0130 fix: simplify method call for writing data in UsbSerialService 2026-03-14 18:41:57 -07:00
zjs81 6dfb7a4b69 fix: auto-add flag parsing, contact cache restore, and USB reconnect
- Fix operator precedence bug in _handleAutoAddConfig where `flags &
  flag != 0` was parsed as `flags & (flag != 0)`, always checking bit 0
  instead of the correct flag bit
- Populate _contacts from cache in loadContactCache() so contacts
  persist across app restarts
- Toggle DTR low→high on USB connect to force device to see a fresh
  connection
- Add 10ms inter-frame delay for USB sends to prevent missed responses
- Deassert DTR before closing USB port on disconnect/dispose

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:41:21 -07:00
zjs81 28a423e0a8 fix: correct location validation and clean up target contact handling
- Fix asymmetric lat/lon validation in _handleContactAdvert (was checking
  longitude != 0 for latitude; now uses (latitude != 0 || longitude != 0)
  for both)
- Remove duplicate targetGuessed assignment in path_trace_map
- Rename public target field to private _targetContact, use local variable
  to avoid unnecessary null-aware operators

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:14:39 -07:00
Winston Lowe 3593cfa843 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:44 -07:00
Winston Lowe dc85e7a41c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:17 -07:00
Winston Lowe 9265daaf16 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:10:09 -07:00
Winston Lowe 4b744184c2 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 18:09:54 -07:00
Winston Lowe 06a906f4f7 Enhance location handling and improve path trace functionality across screens 2026-03-14 17:51:24 -07:00
Winston Lowe 24fa78741b add TCP server address and port settings to AppSettings and update TcpScreen 2026-03-14 11:46:05 -07:00
Winston Lowe 79a45c527b Unify contact retrieval by introducing allContacts getter 2026-03-14 11:45:47 -07:00
zjs81 8b280b37be Merge pull request #293 from zjs81/map-set-location-and-connector-improvements
feat: add set-as-my-location from map long-press, connector and UI
2026-03-14 09:55:02 -07:00
zjs81 fa4da979af feat: enhance location update feedback and improve message retry error handling 2026-03-14 09:54:50 -07:00
zjs81 91608ff09e feat: improve message matching logic and update notification IDs for advertisements 2026-03-14 09:44:37 -07:00
zjs81 71f59d23df feat: add set-as-my-location from map long-press, connector and UI improvements
Add "Set as my location" option to the map long-press bottom sheet,
allowing users to set their device position directly from the map.
Includes connector, chat, contacts, and message retry service improvements.
2026-03-14 09:33:37 -07:00
61 changed files with 1114 additions and 1049 deletions
+1
View File
@@ -0,0 +1 @@
6.2.4
+83 -13
View File
@@ -199,6 +199,9 @@ class MeshCoreConnector extends ChangeNotifier {
int _queueSyncRetries = 0; int _queueSyncRetries = 0;
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Serializes path operations (setContactPath/clearContactPath) to prevent
// interleaved async calls from leaving in-memory state inconsistent with device.
Future<void> _pathOpLock = Future.value();
Map<String, String>? _currentCustomVars; Map<String, String>? _currentCustomVars;
// Channel syncing state (sequential pattern) // Channel syncing state (sequential pattern)
@@ -286,6 +289,10 @@ class MeshCoreConnector extends ChangeNotifier {
); );
} }
List<Contact> get allContacts => List.unmodifiable([
..._contacts,
..._discoveredContacts.where((c) => !c.isActive),
]);
List<Contact> get discoveredContacts { List<Contact> get discoveredContacts {
return List.unmodifiable(_discoveredContacts); return List.unmodifiable(_discoveredContacts);
} }
@@ -558,6 +565,10 @@ class MeshCoreConnector extends ChangeNotifier {
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount), Map<String, int>.from(_contactUnreadCount),
); );
_notificationService.clearContactNotification(
contactKeyHex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -576,6 +587,10 @@ class MeshCoreConnector extends ChangeNotifier {
_channels.isNotEmpty ? _channels : _cachedChannels, _channels.isNotEmpty ? _channels : _cachedChannels,
), ),
); );
_notificationService.clearChannelNotification(
channelIndex,
getTotalUnreadCount(),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -693,6 +708,9 @@ class MeshCoreConnector extends ChangeNotifier {
_knownContactKeys _knownContactKeys
..clear() ..clear()
..addAll(cached.map((c) => c.publicKeyHex)); ..addAll(cached.map((c) => c.publicKeyHex));
_contacts
..clear()
..addAll(cached);
for (final contact in cached) { for (final contact in cached) {
_ensureContactSmazSettingLoaded(contact.publicKeyHex); _ensureContactSmazSettingLoaded(contact.publicKeyHex);
} }
@@ -1525,6 +1543,10 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb) { if (_activeTransport == MeshCoreTransportType.usb) {
await _usbManager.write(data); await _usbManager.write(data);
// Brief pause so the device firmware can process each frame before the
// next arrives. Without this, rapid-fire frames over USB can cause the
// device to miss responses (especially on reconnect).
await Future<void>.delayed(const Duration(milliseconds: 10));
} else if (_activeTransport == MeshCoreTransportType.tcp) { } else if (_activeTransport == MeshCoreTransportType.tcp) {
await _tcpConnector.write(data); await _tcpConnector.write(data);
} else { } else {
@@ -1740,6 +1762,13 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List customPath, Uint8List customPath,
int pathLen, int pathLen,
) async { ) async {
// Serialize path operations to prevent interleaved async calls from
// leaving in-memory state inconsistent with the device.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return; if (!isConnected) return;
await sendFrame( await sendFrame(
@@ -1752,6 +1781,14 @@ class MeshCoreConnector extends ChangeNotifier {
name: contact.name, name: contact.name,
), ),
); );
// USB writes return instantly (no BLE flow control), so give the firmware
// time to persist the path change before subsequent commands.
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
} finally {
completer.complete();
}
} }
Future<void> setContactFavorite(Contact contact, bool isFavorite) async { Future<void> setContactFavorite(Contact contact, bool isFavorite) async {
@@ -2136,25 +2173,34 @@ class MeshCoreConnector extends ChangeNotifier {
} }
Future<void> clearContactPath(Contact contact) async { Future<void> clearContactPath(Contact contact) async {
// Serialize path operations to prevent interleaved async calls.
final prev = _pathOpLock;
final completer = Completer<void>();
_pathOpLock = completer.future;
await prev;
try {
if (!isConnected) return; if (!isConnected) return;
await sendFrame(buildResetPathFrame(contact.publicKey)); await sendFrame(buildResetPathFrame(contact.publicKey));
if (_activeTransport == MeshCoreTransportType.usb) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
final existingIndex = _contacts.indexWhere( final existingIndex = _contacts.indexWhere(
(c) => c.publicKeyHex == contact.publicKeyHex, (c) => c.publicKeyHex == contact.publicKeyHex,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
final existing = _contacts[existingIndex]; final existing = _contacts[existingIndex];
// Use copyWith to preserve pathOverride and pathOverrideBytes // Preserve pathOverride and pathOverrideBytes — only reset device path
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
pathOverride: null,
pathOverrideBytes: null,
pathLength: -1, pathLength: -1,
path: Uint8List(0), path: Uint8List(0),
); );
notifyListeners(); notifyListeners();
unawaited(_persistContacts()); unawaited(_persistContacts());
} }
// The device will send updated contact info with path_len = -1 } finally {
completer.complete();
}
} }
void updateContactInMemory( void updateContactInMemory(
@@ -2490,6 +2536,9 @@ class MeshCoreConnector extends ChangeNotifier {
_isLoadingContacts = true; _isLoadingContacts = true;
notifyListeners(); notifyListeners();
break; break;
case pushCodeAdvert:
// Known contact was seen again - just a pub key, no action needed
break;
case pushCodeNewAdvert: case pushCodeNewAdvert:
debugPrint('Got New CONTACT'); debugPrint('Got New CONTACT');
// It's the same format as respCodeContact, so we can reuse the handler // It's the same format as respCodeContact, so we can reuse the handler
@@ -2871,6 +2920,8 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleContact(Uint8List frame, {bool isContact = true}) { void _handleContact(Uint8List frame, {bool isContact = true}) {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_contactUnreadCount.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount( _unreadStore.saveContactUnreadCount(
@@ -4679,6 +4730,12 @@ class MeshCoreConnector extends ChangeNotifier {
(_autoAddRoomServers && type == advTypeRoom) || (_autoAddRoomServers && type == advTypeRoom) ||
(_autoAddSensors && type == advTypeSensor)) { (_autoAddSensors && type == advTypeSensor)) {
_handleContactAdvert(newContact); _handleContactAdvert(newContact);
_handleDiscovery(
newContact,
rawPacket,
noNotify: true,
addActive: true,
);
} else { } else {
_handleDiscovery(newContact, rawPacket); _handleDiscovery(newContact, rawPacket);
} }
@@ -4703,8 +4760,20 @@ class MeshCoreConnector extends ChangeNotifier {
// CRITICAL: Preserve user's path override when contact is refreshed from device // CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = existing.copyWith( _contacts[existingIndex] = existing.copyWith(
latitude: hasLocation ? latitude : existing.latitude, latitude:
longitude: hasLocation ? longitude : existing.longitude, hasLocation &&
latitude != null &&
latitude.abs() <= 90 &&
(latitude != 0 || longitude != 0)
? latitude
: existing.latitude,
longitude:
hasLocation &&
longitude != null &&
longitude.abs() <= 180 &&
(latitude != 0 || longitude != 0)
? longitude
: existing.longitude,
name: hasName ? name : existing.name, name: hasName ? name : existing.name,
path: Uint8List.fromList(path.reversed.toList()), path: Uint8List.fromList(path.reversed.toList()),
pathLength: path.length, pathLength: path.length,
@@ -4775,11 +4844,11 @@ class MeshCoreConnector extends ChangeNotifier {
try { try {
reader.skipBytes(1); // Skip the response code byte reader.skipBytes(1); // Skip the response code byte
final flags = reader.readByte(); final flags = reader.readByte();
_autoAddUsers = flags & autoAddChatFlag != 0; _autoAddUsers = (flags & autoAddChatFlag) != 0;
_autoAddRepeaters = flags & autoAddRepeaterFlag != 0; _autoAddRepeaters = (flags & autoAddRepeaterFlag) != 0;
_autoAddRoomServers = flags & autoAddRoomServerFlag != 0; _autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = flags & autoAddSensorFlag != 0; _autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = flags & autoAddOverwriteOldestFlag != 0; _overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
} catch (e) { } catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector'); appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
} }
@@ -4789,6 +4858,7 @@ class MeshCoreConnector extends ChangeNotifier {
Contact contact, Contact contact,
Uint8List rawPacket, { Uint8List rawPacket, {
bool noNotify = false, bool noNotify = false,
bool addActive = false,
}) { }) {
appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector'); appLogger.info('Discovered new contact: ${contact.name}', tag: 'Connector');
@@ -4809,7 +4879,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
flags: 0, flags: 0,
isActive: false, isActive: addActive,
); );
notifyListeners(); notifyListeners();
unawaited(_persistDiscoveredContacts()); unawaited(_persistDiscoveredContacts());
@@ -4827,7 +4897,7 @@ class MeshCoreConnector extends ChangeNotifier {
longitude: contact.longitude, longitude: contact.longitude,
lastSeen: contact.lastSeen, lastSeen: contact.lastSeen,
lastMessageAt: contact.lastMessageAt, lastMessageAt: contact.lastMessageAt,
isActive: false, isActive: addActive,
flags: 0, flags: 0,
); );
_discoveredContacts.add(disContact); _discoveredContacts.add(disContact);
@@ -64,6 +64,8 @@ class MeshCoreUsbManager {
Future<void> write(Uint8List data) => _service.write(data); Future<void> write(Uint8List data) => _service.write(data);
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
// --- Label management --- // --- Label management ---
void updateConnectedLabel(String selfName) { void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName); _service.updateConnectedLabel(selfName);
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Нова група", "contacts_newGroup": "Нова група",
"contacts_groupName": "Група", "contacts_groupName": "Група",
"contacts_groupNameRequired": "Името на групата е задължително.", "contacts_groupNameRequired": "Името на групата е задължително.",
"contacts_groupNameReserved": "Това име на група е запазено",
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.", "contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
"tcpErrorTimedOut": "Връзката TCP изтекла.", "tcpErrorTimedOut": "Връзката TCP изтекла.",
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}", "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
"map_showDiscoveryContacts": "Покажи контакти за откриване" "map_showDiscoveryContacts": "Покажи контакти за откриване",
"map_setAsMyLocation": "Задайте като моя местоположение"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Neue Gruppe", "contacts_newGroup": "Neue Gruppe",
"contacts_groupName": "Gruppenname", "contacts_groupName": "Gruppenname",
"contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.", "contacts_groupNameRequired": "Der Gruppennamen ist erforderlich.",
"contacts_groupNameReserved": "Dieser Gruppenname ist reserviert",
"contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.", "contacts_groupAlreadyExists": "Die Gruppe \"{name}\" existiert bereits.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1916,5 +1915,6 @@
"tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.",
"tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.",
"tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}", "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}",
"map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen" "map_showDiscoveryContacts": "Entdeckungs-Kontakte anzeigen",
"map_setAsMyLocation": "Als meine aktuelle Position festlegen"
} }
+1 -1
View File
@@ -416,7 +416,6 @@
"contacts_newGroup": "New Group", "contacts_newGroup": "New Group",
"contacts_groupName": "Group name", "contacts_groupName": "Group name",
"contacts_groupNameRequired": "Group name is required", "contacts_groupNameRequired": "Group name is required",
"contacts_groupNameReserved": "This group name is reserved",
"contacts_groupAlreadyExists": "Group \"{name}\" already exists", "contacts_groupAlreadyExists": "Group \"{name}\" already exists",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -808,6 +807,7 @@
"map_source": "Source", "map_source": "Source",
"map_flags": "Flags", "map_flags": "Flags",
"map_shareMarkerHere": "Share marker here", "map_shareMarkerHere": "Share marker here",
"map_setAsMyLocation": "Set as my location",
"map_pinLabel": "Pin label", "map_pinLabel": "Pin label",
"map_label": "Label", "map_label": "Label",
"map_pointOfInterest": "Point of interest", "map_pointOfInterest": "Point of interest",
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nuevo Grupo", "contacts_newGroup": "Nuevo Grupo",
"contacts_groupName": "Nombre del grupo", "contacts_groupName": "Nombre del grupo",
"contacts_groupNameRequired": "El nombre del grupo es obligatorio", "contacts_groupNameRequired": "El nombre del grupo es obligatorio",
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe", "contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1916,5 +1915,6 @@
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.", "tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
"tcpErrorTimedOut": "La conexión TCP ha caducado.", "tcpErrorTimedOut": "La conexión TCP ha caducado.",
"tcpConnectionFailed": "Error en la conexión TCP: {error}", "tcpConnectionFailed": "Error en la conexión TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento" "map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
"map_setAsMyLocation": "Establecer mi ubicación"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nouveau Groupe", "contacts_newGroup": "Nouveau Groupe",
"contacts_groupName": "Nom du groupe", "contacts_groupName": "Nom du groupe",
"contacts_groupNameRequired": "Le nom du groupe est obligatoire.", "contacts_groupNameRequired": "Le nom du groupe est obligatoire.",
"contacts_groupNameReserved": "Ce nom de groupe est réservé",
"contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.", "contacts_groupAlreadyExists": "Le groupe \"{name}\" existe déjà.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.", "tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.",
"tcpErrorTimedOut": "La connexion TCP a expiré.", "tcpErrorTimedOut": "La connexion TCP a expiré.",
"tcpConnectionFailed": "Échec de la connexion TCP : {error}", "tcpConnectionFailed": "Échec de la connexion TCP : {error}",
"map_showDiscoveryContacts": "Afficher les contacts de découverte" "map_showDiscoveryContacts": "Afficher les contacts de découverte",
"map_setAsMyLocation": "Définir comme ma localisation"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nuovo Gruppo", "contacts_newGroup": "Nuovo Gruppo",
"contacts_groupName": "Nome gruppo", "contacts_groupName": "Nome gruppo",
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.", "contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.", "contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
"tcpErrorTimedOut": "La connessione TCP è scaduta.", "tcpErrorTimedOut": "La connessione TCP è scaduta.",
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}", "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
"map_showDiscoveryContacts": "Mostra Contatti di Discovery" "map_showDiscoveryContacts": "Mostra Contatti di Discovery",
"map_setAsMyLocation": "Imposta come la mia posizione"
} }
+6 -6
View File
@@ -1714,12 +1714,6 @@ abstract class AppLocalizations {
/// **'Group name is required'** /// **'Group name is required'**
String get contacts_groupNameRequired; String get contacts_groupNameRequired;
/// No description provided for @contacts_groupNameReserved.
///
/// In en, this message translates to:
/// **'This group name is reserved'**
String get contacts_groupNameReserved;
/// No description provided for @contacts_groupAlreadyExists. /// No description provided for @contacts_groupAlreadyExists.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2752,6 +2746,12 @@ abstract class AppLocalizations {
/// **'Share marker here'** /// **'Share marker here'**
String get map_shareMarkerHere; String get map_shareMarkerHere;
/// No description provided for @map_setAsMyLocation.
///
/// In en, this message translates to:
/// **'Set as my location'**
String get map_setAsMyLocation;
/// No description provided for @map_pinLabel. /// No description provided for @map_pinLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+3 -3
View File
@@ -902,9 +902,6 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Името на групата е задължително.'; String get contacts_groupNameRequired => 'Името на групата е задължително.';
@override
String get contacts_groupNameReserved => 'Това име на група е запазено';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Групата \"$name\" вече съществува.'; return 'Групата \"$name\" вече съществува.';
@@ -1514,6 +1511,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Споделете маркер тук'; String get map_shareMarkerHere => 'Споделете маркер тук';
@override
String get map_setAsMyLocation => 'Задайте като моя местоположение';
@override @override
String get map_pinLabel => 'Етикетиране на пин'; String get map_pinLabel => 'Етикетиране на пин';
+3 -3
View File
@@ -902,9 +902,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.'; String get contacts_groupNameRequired => 'Der Gruppennamen ist erforderlich.';
@override
String get contacts_groupNameReserved => 'Dieser Gruppenname ist reserviert';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Die Gruppe \"$name\" existiert bereits.'; return 'Die Gruppe \"$name\" existiert bereits.';
@@ -1516,6 +1513,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Teilen Sie den Marker hier.'; String get map_shareMarkerHere => 'Teilen Sie den Marker hier.';
@override
String get map_setAsMyLocation => 'Als meine aktuelle Position festlegen';
@override @override
String get map_pinLabel => 'Pin Name'; String get map_pinLabel => 'Pin Name';
+3 -3
View File
@@ -889,9 +889,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Group name is required'; String get contacts_groupNameRequired => 'Group name is required';
@override
String get contacts_groupNameReserved => 'This group name is reserved';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Group \"$name\" already exists'; return 'Group \"$name\" already exists';
@@ -1490,6 +1487,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Share marker here'; String get map_shareMarkerHere => 'Share marker here';
@override
String get map_setAsMyLocation => 'Set as my location';
@override @override
String get map_pinLabel => 'Pin label'; String get map_pinLabel => 'Pin label';
+3 -4
View File
@@ -901,10 +901,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio'; String get contacts_groupNameRequired => 'El nombre del grupo es obligatorio';
@override
String get contacts_groupNameReserved =>
'Este nombre de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'El grupo \"$name\" ya existe'; return 'El grupo \"$name\" ya existe';
@@ -1513,6 +1509,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartir marcador aquí'; String get map_shareMarkerHere => 'Compartir marcador aquí';
@override
String get map_setAsMyLocation => 'Establecer mi ubicación';
@override @override
String get map_pinLabel => 'Etiqueta de marcador'; String get map_pinLabel => 'Etiqueta de marcador';
+3 -3
View File
@@ -905,9 +905,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.'; String get contacts_groupNameRequired => 'Le nom du groupe est obligatoire.';
@override
String get contacts_groupNameReserved => 'Ce nom de groupe est réservé';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Le groupe \"$name\" existe déjà.'; return 'Le groupe \"$name\" existe déjà.';
@@ -1521,6 +1518,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Partager le marqueur ici'; String get map_shareMarkerHere => 'Partager le marqueur ici';
@override
String get map_setAsMyLocation => 'Définir comme ma localisation';
@override @override
String get map_pinLabel => 'Étiquete de repin'; String get map_pinLabel => 'Étiquete de repin';
+3 -3
View File
@@ -901,9 +901,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.'; String get contacts_groupNameRequired => 'Il nome del gruppo è obbligatorio.';
@override
String get contacts_groupNameReserved => 'Questo nome del gruppo è riservato';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Il gruppo \"$name\" esiste già.'; return 'Il gruppo \"$name\" esiste già.';
@@ -1513,6 +1510,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Condividi marcatore qui'; String get map_shareMarkerHere => 'Condividi marcatore qui';
@override
String get map_setAsMyLocation => 'Imposta come la mia posizione';
@override @override
String get map_pinLabel => 'Etichetta PIN'; String get map_pinLabel => 'Etichetta PIN';
+3 -3
View File
@@ -895,9 +895,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'De groepnaam is verplicht.'; String get contacts_groupNameRequired => 'De groepnaam is verplicht.';
@override
String get contacts_groupNameReserved => 'Deze groepsnaam is gereserveerd';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'De groep \"$name\" bestaat al.'; return 'De groep \"$name\" bestaat al.';
@@ -1505,6 +1502,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Deel marker hier'; String get map_shareMarkerHere => 'Deel marker hier';
@override
String get map_setAsMyLocation => 'Stel dit in als mijn locatie';
@override @override
String get map_pinLabel => 'Label vastzetten'; String get map_pinLabel => 'Label vastzetten';
+3 -3
View File
@@ -904,9 +904,6 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana'; String get contacts_groupNameRequired => 'Nazwa grupy jest wymagana';
@override
String get contacts_groupNameReserved => 'Ta nazwa grupy jest zastrzeżona';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Grupa \"$name\" już istnieje'; return 'Grupa \"$name\" już istnieje';
@@ -1515,6 +1512,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Udostępnij znacznik tutaj'; String get map_shareMarkerHere => 'Udostępnij znacznik tutaj';
@override
String get map_setAsMyLocation => 'Ustaw jako moje lokalizację';
@override @override
String get map_pinLabel => 'Oznacz etykietę'; String get map_pinLabel => 'Oznacz etykietę';
+3 -3
View File
@@ -903,9 +903,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.'; String get contacts_groupNameRequired => 'O nome do grupo é obrigatório.';
@override
String get contacts_groupNameReserved => 'Este nome de grupo está reservado';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'O grupo \"$name\" já existe'; return 'O grupo \"$name\" já existe';
@@ -1514,6 +1511,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Compartilhar marcador aqui'; String get map_shareMarkerHere => 'Compartilhar marcador aqui';
@override
String get map_setAsMyLocation => 'Defina minha localização';
@override @override
String get map_pinLabel => 'Rótulo de marcador'; String get map_pinLabel => 'Rótulo de marcador';
+3 -3
View File
@@ -902,9 +902,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Имя группы обязательно'; String get contacts_groupNameRequired => 'Имя группы обязательно';
@override
String get contacts_groupNameReserved => 'Это имя группы зарезервировано';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Группа \"$name\" уже существует'; return 'Группа \"$name\" уже существует';
@@ -1516,6 +1513,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поделиться меткой здесь'; String get map_shareMarkerHere => 'Поделиться меткой здесь';
@override
String get map_setAsMyLocation => 'Установить мое местоположение';
@override @override
String get map_pinLabel => 'Метка'; String get map_pinLabel => 'Метка';
+3 -3
View File
@@ -894,9 +894,6 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Skupina musí mať názov.'; String get contacts_groupNameRequired => 'Skupina musí mať názov.';
@override
String get contacts_groupNameReserved => 'Tento názov skupiny je rezervovaný';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" už existuje'; return 'Skupina \"$name\" už existuje';
@@ -1507,6 +1504,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Zdieľte značku tu'; String get map_shareMarkerHere => 'Zdieľte značku tu';
@override
String get map_setAsMyLocation => 'Nastavte ako moju polohu';
@override @override
String get map_pinLabel => 'Označka upozornenia'; String get map_pinLabel => 'Označka upozornenia';
+3 -3
View File
@@ -892,9 +892,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Ime skupine je obvezno.'; String get contacts_groupNameRequired => 'Ime skupine je obvezno.';
@override
String get contacts_groupNameReserved => 'To ime skupine je rezervirano';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Skupina \"$name\" že obstaja'; return 'Skupina \"$name\" že obstaja';
@@ -1501,6 +1498,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Delite točke tukaj.'; String get map_shareMarkerHere => 'Delite točke tukaj.';
@override
String get map_setAsMyLocation => 'Nastavite to kot mojo lokacijo';
@override @override
String get map_pinLabel => 'Oznaka za pritrditev'; String get map_pinLabel => 'Oznaka za pritrditev';
+3 -3
View File
@@ -888,9 +888,6 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt'; String get contacts_groupNameRequired => 'Gruppnamnet är obligatoriskt';
@override
String get contacts_groupNameReserved => 'Detta gruppnamn är reserverat';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Gruppen \"$name\" finns redan.'; return 'Gruppen \"$name\" finns redan.';
@@ -1497,6 +1494,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Dela markeringen här'; String get map_shareMarkerHere => 'Dela markeringen här';
@override
String get map_setAsMyLocation => 'Ange som min plats';
@override @override
String get map_pinLabel => 'Fästetikett'; String get map_pinLabel => 'Fästetikett';
+3 -3
View File
@@ -898,9 +898,6 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => 'Назва групи обов\'язкова.'; String get contacts_groupNameRequired => 'Назва групи обов\'язкова.';
@override
String get contacts_groupNameReserved => 'Ця назва групи зарезервована';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return 'Група «$name» вже існує.'; return 'Група «$name» вже існує.';
@@ -1513,6 +1510,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get map_shareMarkerHere => 'Поділитися маркером тут'; String get map_shareMarkerHere => 'Поділитися маркером тут';
@override
String get map_setAsMyLocation => 'Встановити моє місцезнаходження';
@override @override
String get map_pinLabel => 'Мітка піна'; String get map_pinLabel => 'Мітка піна';
+3 -3
View File
@@ -845,9 +845,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get contacts_groupNameRequired => '请输入群聊名称'; String get contacts_groupNameRequired => '请输入群聊名称';
@override
String get contacts_groupNameReserved => '该群组名称已被保留';
@override @override
String contacts_groupAlreadyExists(String name) { String contacts_groupAlreadyExists(String name) {
return '名为 \"$name\" 的群聊已存在'; return '名为 \"$name\" 的群聊已存在';
@@ -1424,6 +1421,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get map_shareMarkerHere => '在此分享标记'; String get map_shareMarkerHere => '在此分享标记';
@override
String get map_setAsMyLocation => '设置为我的位置';
@override @override
String get map_pinLabel => '标签'; String get map_pinLabel => '标签';
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nieuwe Groep", "contacts_newGroup": "Nieuwe Groep",
"contacts_groupName": "Groepnaam", "contacts_groupName": "Groepnaam",
"contacts_groupNameRequired": "De groepnaam is verplicht.", "contacts_groupNameRequired": "De groepnaam is verplicht.",
"contacts_groupNameReserved": "Deze groepsnaam is gereserveerd",
"contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.", "contacts_groupAlreadyExists": "De groep \"{name}\" bestaat al.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.",
"tcpErrorTimedOut": "De TCP-verbinding is verlopen.", "tcpErrorTimedOut": "De TCP-verbinding is verlopen.",
"tcpConnectionFailed": "Verbinding met TCP mislukt: {error}", "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}",
"map_showDiscoveryContacts": "Ontdek contacten weergeven" "map_showDiscoveryContacts": "Ontdek contacten weergeven",
"map_setAsMyLocation": "Stel dit in als mijn locatie"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nowa Grupa", "contacts_newGroup": "Nowa Grupa",
"contacts_groupName": "Nazwa grupy", "contacts_groupName": "Nazwa grupy",
"contacts_groupNameRequired": "Nazwa grupy jest wymagana", "contacts_groupNameRequired": "Nazwa grupy jest wymagana",
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje", "contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.", "tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
"tcpConnectionFailed": "Błąd połączenia TCP: {error}", "tcpConnectionFailed": "Błąd połączenia TCP: {error}",
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania" "map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
"map_setAsMyLocation": "Ustaw jako moje lokalizację"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Novo Grupo", "contacts_newGroup": "Novo Grupo",
"contacts_groupName": "Nome do grupo", "contacts_groupName": "Nome do grupo",
"contacts_groupNameRequired": "O nome do grupo é obrigatório.", "contacts_groupNameRequired": "O nome do grupo é obrigatório.",
"contacts_groupNameReserved": "Este nome de grupo está reservado",
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe", "contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
"tcpErrorTimedOut": "A conexão TCP expirou.", "tcpErrorTimedOut": "A conexão TCP expirou.",
"tcpConnectionFailed": "Falha na conexão TCP: {error}", "tcpConnectionFailed": "Falha na conexão TCP: {error}",
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta" "map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
"map_setAsMyLocation": "Defina minha localização"
} }
+2 -2
View File
@@ -212,7 +212,6 @@
"contacts_newGroup": "Новая группа", "contacts_newGroup": "Новая группа",
"contacts_groupName": "Имя группы", "contacts_groupName": "Имя группы",
"contacts_groupNameRequired": "Имя группы обязательно", "contacts_groupNameRequired": "Имя группы обязательно",
"contacts_groupNameReserved": "Это имя группы зарезервировано",
"contacts_groupAlreadyExists": "Группа \"{name}\" уже существует", "contacts_groupAlreadyExists": "Группа \"{name}\" уже существует",
"contacts_filterContacts": "Фильтр контактов...", "contacts_filterContacts": "Фильтр контактов...",
"contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру", "contacts_noContactsMatchFilter": "Нет контактов, соответствующих фильтру",
@@ -1128,5 +1127,6 @@
"tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.",
"tcpErrorTimedOut": "Соединение TCP не удалось установить.", "tcpErrorTimedOut": "Соединение TCP не удалось установить.",
"tcpConnectionFailed": "Не удалось установить соединение TCP: {error}", "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}",
"map_showDiscoveryContacts": "Показать контакты Discovery" "map_showDiscoveryContacts": "Показать контакты Discovery",
"map_setAsMyLocation": "Установить мое местоположение"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nová skupina", "contacts_newGroup": "Nová skupina",
"contacts_groupName": "Názov skupiny", "contacts_groupName": "Názov skupiny",
"contacts_groupNameRequired": "Skupina musí mať názov.", "contacts_groupNameRequired": "Skupina musí mať názov.",
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje", "contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.", "tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.", "tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}", "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
"map_showDiscoveryContacts": "Zobraziť kontakty objavov" "map_showDiscoveryContacts": "Zobraziť kontakty objavov",
"map_setAsMyLocation": "Nastavte ako moju polohu"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Nova skupina", "contacts_newGroup": "Nova skupina",
"contacts_groupName": "Ime skupine", "contacts_groupName": "Ime skupine",
"contacts_groupNameRequired": "Ime skupine je obvezno.", "contacts_groupNameRequired": "Ime skupine je obvezno.",
"contacts_groupNameReserved": "To ime skupine je rezervirano",
"contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja", "contacts_groupAlreadyExists": "Skupina \"{name}\" že obstaja",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.",
"tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.",
"tcpConnectionFailed": "Napaka pri povezavi TCP: {error}", "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}",
"map_showDiscoveryContacts": "Prikaži odkritja kontaktov" "map_showDiscoveryContacts": "Prikaži odkritja kontaktov",
"map_setAsMyLocation": "Nastavite to kot mojo lokacijo"
} }
+2 -2
View File
@@ -285,7 +285,6 @@
"contacts_newGroup": "Ny grupp", "contacts_newGroup": "Ny grupp",
"contacts_groupName": "Gruppnamn", "contacts_groupName": "Gruppnamn",
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt", "contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.", "contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}", "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
"map_showDiscoveryContacts": "Visa Discovery-kontakter" "map_showDiscoveryContacts": "Visa Discovery-kontakter",
"map_setAsMyLocation": "Ange som min plats"
} }
+2 -2
View File
@@ -286,7 +286,6 @@
"contacts_newGroup": "Нова група", "contacts_newGroup": "Нова група",
"contacts_groupName": "Назва групи", "contacts_groupName": "Назва групи",
"contacts_groupNameRequired": "Назва групи обов'язкова.", "contacts_groupNameRequired": "Назва групи обов'язкова.",
"contacts_groupNameReserved": "Ця назва групи зарезервована",
"contacts_groupAlreadyExists": "Група «{name}» вже існує.", "contacts_groupAlreadyExists": "Група «{name}» вже існує.",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1888,5 +1887,6 @@
"tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.",
"tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.",
"tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}", "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}",
"map_showDiscoveryContacts": "Показати контакти Відкриття" "map_showDiscoveryContacts": "Показати контакти Відкриття",
"map_setAsMyLocation": "Встановити моє місцезнаходження"
} }
+2 -2
View File
@@ -300,7 +300,6 @@
"contacts_newGroup": "新建群聊", "contacts_newGroup": "新建群聊",
"contacts_groupName": "群聊名称", "contacts_groupName": "群聊名称",
"contacts_groupNameRequired": "请输入群聊名称", "contacts_groupNameRequired": "请输入群聊名称",
"contacts_groupNameReserved": "该群组名称已被保留",
"contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在", "contacts_groupAlreadyExists": "名为 \"{name}\" 的群聊已存在",
"@contacts_groupAlreadyExists": { "@contacts_groupAlreadyExists": {
"placeholders": { "placeholders": {
@@ -1893,5 +1892,6 @@
"tcpErrorUnsupported": "此平台不支持 TCP 传输。", "tcpErrorUnsupported": "此平台不支持 TCP 传输。",
"tcpErrorTimedOut": "TCP 连接超时。", "tcpErrorTimedOut": "TCP 连接超时。",
"tcpConnectionFailed": "TCP 连接失败:{error}", "tcpConnectionFailed": "TCP 连接失败:{error}",
"map_showDiscoveryContacts": "显示发现联系人" "map_showDiscoveryContacts": "显示发现联系人",
"map_setAsMyLocation": "设置为我的位置"
} }
-7
View File
@@ -19,7 +19,6 @@ import 'services/app_debug_log_service.dart';
import 'services/background_service.dart'; import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart'; import 'services/map_tile_cache_service.dart';
import 'services/chat_text_scale_service.dart'; import 'services/chat_text_scale_service.dart';
import 'services/ui_view_state_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@@ -40,7 +39,6 @@ void main() async {
final backgroundService = BackgroundService(); final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(); final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService(); final chatTextScaleService = ChatTextScaleService();
final uiViewStateService = UiViewStateService();
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
@@ -58,7 +56,6 @@ void main() async {
_registerThirdPartyLicenses(); _registerThirdPartyLicenses();
await chatTextScaleService.initialize(); await chatTextScaleService.initialize();
await uiViewStateService.initialize();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@@ -89,7 +86,6 @@ void main() async {
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService, mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService, chatTextScaleService: chatTextScaleService,
uiViewStateService: uiViewStateService,
), ),
); );
} }
@@ -125,7 +121,6 @@ class MeshCoreApp extends StatelessWidget {
final AppDebugLogService appDebugLogService; final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService; final MapTileCacheService mapTileCacheService;
final ChatTextScaleService chatTextScaleService; final ChatTextScaleService chatTextScaleService;
final UiViewStateService uiViewStateService;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@@ -138,7 +133,6 @@ class MeshCoreApp extends StatelessWidget {
required this.appDebugLogService, required this.appDebugLogService,
required this.mapTileCacheService, required this.mapTileCacheService,
required this.chatTextScaleService, required this.chatTextScaleService,
required this.uiViewStateService,
}); });
@override @override
@@ -152,7 +146,6 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService),
ChangeNotifierProvider.value(value: chatTextScaleService), ChangeNotifierProvider.value(value: chatTextScaleService),
ChangeNotifierProvider.value(value: uiViewStateService),
Provider.value(value: storage), Provider.value(value: storage),
Provider.value(value: mapTileCacheService), Provider.value(value: mapTileCacheService),
], ],
+12
View File
@@ -40,6 +40,8 @@ class AppSettings {
final UnitSystem unitSystem; final UnitSystem unitSystem;
final Set<String> mutedChannels; final Set<String> mutedChannels;
final bool mapShowDiscoveryContacts; final bool mapShowDiscoveryContacts;
final String tcpServerAddress;
final int tcpServerPort;
AppSettings({ AppSettings({
this.clearPathOnMaxRetry = false, this.clearPathOnMaxRetry = false,
@@ -68,6 +70,8 @@ class AppSettings {
this.unitSystem = UnitSystem.metric, this.unitSystem = UnitSystem.metric,
Set<String>? mutedChannels, Set<String>? mutedChannels,
this.mapShowDiscoveryContacts = true, this.mapShowDiscoveryContacts = true,
this.tcpServerAddress = '',
this.tcpServerPort = 0,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {}, batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
mutedChannels = mutedChannels ?? {}; mutedChannels = mutedChannels ?? {};
@@ -100,6 +104,8 @@ class AppSettings {
'unit_system': unitSystem.value, 'unit_system': unitSystem.value,
'muted_channels': mutedChannels.toList(), 'muted_channels': mutedChannels.toList(),
'map_show_discovery_contacts': mapShowDiscoveryContacts, 'map_show_discovery_contacts': mapShowDiscoveryContacts,
'tcp_server_address': tcpServerAddress,
'tcp_server_port': tcpServerPort,
}; };
} }
@@ -157,6 +163,8 @@ class AppSettings {
{}, {},
mapShowDiscoveryContacts: mapShowDiscoveryContacts:
json['map_show_discovery_contacts'] as bool? ?? true, json['map_show_discovery_contacts'] as bool? ?? true,
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
); );
} }
@@ -187,6 +195,8 @@ class AppSettings {
UnitSystem? unitSystem, UnitSystem? unitSystem,
Set<String>? mutedChannels, Set<String>? mutedChannels,
bool? mapShowDiscoveryContacts, bool? mapShowDiscoveryContacts,
String? tcpServerAddress,
int? tcpServerPort,
}) { }) {
return AppSettings( return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry, clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
@@ -225,6 +235,8 @@ class AppSettings {
mutedChannels: mutedChannels ?? this.mutedChannels, mutedChannels: mutedChannels ?? this.mutedChannels,
mapShowDiscoveryContacts: mapShowDiscoveryContacts:
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts, mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
); );
} }
} }
+14 -39
View File
@@ -65,7 +65,17 @@ class Contact {
return '$pathLength hops'; return '$pathLength hops';
} }
bool get hasLocation => latitude != null && longitude != null; bool get hasLocation {
const double epsilon = 1e-6;
final lat = latitude ?? 0.0;
final lon = longitude ?? 0.0;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
bool get isFavorite => (flags & contactFlagFavorite) != 0; bool get isFavorite => (flags & contactFlagFavorite) != 0;
Contact copyWith({ Contact copyWith({
@@ -108,7 +118,7 @@ class Contact {
} }
String get pathIdList { String get pathIdList {
final pathBytes = _pathBytesForDisplay; final pathBytes = pathBytesForDisplay;
if (pathBytes.isEmpty) return ''; if (pathBytes.isEmpty) return '';
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
@@ -130,43 +140,7 @@ class Contact {
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>"; return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
} }
Uint8List? get traceRouteBytes { Uint8List get pathBytesForDisplay {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if (pathBytes.isEmpty) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}
return traceBytes;
}
Uint8List get _pathBytesForDisplay {
if (pathOverride != null) { if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0); if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0); return pathOverrideBytes ?? Uint8List(0);
@@ -197,6 +171,7 @@ class Contact {
double? lat, lon; double? lat, lon;
final latRaw = reader.readInt32LE(); final latRaw = reader.readInt32LE();
final lonRaw = reader.readInt32LE(); final lonRaw = reader.readInt32LE();
if (latRaw != 0 || lonRaw != 0) { if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6; lat = latRaw / 1e6;
lon = lonRaw / 1e6; lon = lonRaw / 1e6;
+5 -10
View File
@@ -40,10 +40,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
final primaryPath = !channelMessage && !message.isOutgoing final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList()) ? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp; : primaryPathTmp;
final contacts = <Contact>[ final contacts = connector.allContacts;
...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(primaryPath, contacts, l10n); final hops = _buildPathHops(primaryPath, contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
@@ -65,8 +62,9 @@ class ChannelMessagePathScreen extends StatelessWidget {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: primaryPath, path: primaryPath,
flipPathRound: true, flipPathAround: true,
reversePathRound: !message.isOutgoing && !channelMessage, reversePathAround:
!(!channelMessage && !message.isOutgoing),
), ),
), ),
), ),
@@ -367,10 +365,7 @@ class _ChannelMessagePathMapScreenState
: selectedPathTmp; : selectedPathTmp;
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final contacts = <Contact>[ final contacts = connector.allContacts;
...connector.contacts,
...connector.discoveredContacts,
];
final hops = _buildPathHops(selectedPath, contacts, context.l10n); final hops = _buildPathHops(selectedPath, contacts, context.l10n);
final points = <LatLng>[]; final points = <LatLng>[];
+55 -44
View File
@@ -11,7 +11,6 @@ import 'package:uuid/uuid.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/ui_view_state_service.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/community.dart'; import '../models/community.dart';
import '../storage/community_store.dart'; import '../storage/community_store.dart';
@@ -29,6 +28,8 @@ import 'contacts_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum ChannelSortOption { manual, name, latestMessages, unread }
class ChannelsScreen extends StatefulWidget { class ChannelsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -42,7 +43,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore(); final CommunityStore _communityStore = CommunityStore();
String _searchQuery = '';
Timer? _searchDebounce; Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
List<Community> _communities = []; List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup // Cache of PSK hex -> Community for quick lookup
@@ -53,9 +56,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.text = context
.read<UiViewStateService>()
.channelsSearchText;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels(); context.read<MeshCoreConnector>().getChannels();
_loadCommunities(); _loadCommunities();
@@ -110,7 +110,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
final viewState = context.watch<UiViewStateService>();
final channelMessageStore = ChannelMessageStore(); final channelMessageStore = ChannelMessageStore();
channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex; channelMessageStore.setPublicKeyHex = connector.selfPublicKeyHex;
@@ -206,7 +205,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels( final filteredChannels = _filterAndSortChannels(
channels, channels,
connector, connector,
viewState,
); );
return Column( return Column(
@@ -221,19 +219,17 @@ class _ChannelsScreenState extends State<ChannelsScreen>
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (viewState.channelsSearchText.isNotEmpty) if (_searchQuery.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear(); _searchController.clear();
context setState(() {
.read<UiViewStateService>() _searchQuery = '';
.setChannelsSearchText(''); });
}, },
), ),
_buildFilterButton(viewState), _buildFilterButton(),
], ],
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -250,9 +246,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
() { () {
if (!mounted) return; if (!mounted) return;
context setState(() {
.read<UiViewStateService>() _searchQuery = value.toLowerCase();
.setChannelsSearchText(value); });
}, },
); );
}, },
@@ -287,9 +283,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
], ],
) )
: (viewState.channelsSortOption == : (_sortOption == ChannelSortOption.manual &&
ChannelSortOption.manual && _searchQuery.isEmpty)
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder( ? ReorderableListView.builder(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 16,
@@ -589,40 +584,59 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton(UiViewStateService viewState) { Widget _buildFilterButton() {
return SortFilterMenu<ChannelSortOption>( const actionSortManual = 0;
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip, tooltip: context.l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection<ChannelSortOption>( SortFilterMenuSection(
title: context.l10n.channels_sortBy, title: context.l10n.channels_sortBy,
options: [ options: [
SortFilterMenuOption<ChannelSortOption>( SortFilterMenuOption(
value: ChannelSortOption.manual, value: actionSortManual,
label: context.l10n.channels_sortManual, label: context.l10n.channels_sortManual,
checked: viewState.channelsSortOption == ChannelSortOption.manual, checked: _sortOption == ChannelSortOption.manual,
), ),
SortFilterMenuOption<ChannelSortOption>( SortFilterMenuOption(
value: ChannelSortOption.name, value: actionSortName,
label: context.l10n.channels_sortAZ, label: context.l10n.channels_sortAZ,
checked: viewState.channelsSortOption == ChannelSortOption.name, checked: _sortOption == ChannelSortOption.name,
), ),
SortFilterMenuOption<ChannelSortOption>( SortFilterMenuOption(
value: ChannelSortOption.latestMessages, value: actionSortLatest,
label: context.l10n.channels_sortLatestMessages, label: context.l10n.channels_sortLatestMessages,
checked: checked: _sortOption == ChannelSortOption.latestMessages,
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
), ),
SortFilterMenuOption<ChannelSortOption>( SortFilterMenuOption(
value: ChannelSortOption.unread, value: actionSortUnread,
label: context.l10n.channels_sortUnread, label: context.l10n.channels_sortUnread,
checked: viewState.channelsSortOption == ChannelSortOption.unread, checked: _sortOption == ChannelSortOption.unread,
), ),
], ],
), ),
], ],
onSelected: (sortOption) { onSelected: (action) {
viewState.setChannelsSortOption(sortOption); setState(() {
switch (action) {
case actionSortManual:
_sortOption = ChannelSortOption.manual;
break;
case actionSortLatest:
_sortOption = ChannelSortOption.latestMessages;
break;
case actionSortUnread:
_sortOption = ChannelSortOption.unread;
break;
case actionSortName:
default:
_sortOption = ChannelSortOption.name;
break;
}
});
}, },
); );
} }
@@ -630,14 +644,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
List<Channel> _filterAndSortChannels( List<Channel> _filterAndSortChannels(
List<Channel> channels, List<Channel> channels,
MeshCoreConnector connector, MeshCoreConnector connector,
UiViewStateService viewState,
) { ) {
var filtered = channels.where((channel) { var filtered = channels.where((channel) {
if (viewState.channelsSearchText.isEmpty) return true; if (_searchQuery.isEmpty) return true;
final label = _normalizeChannelName(channel); final label = _normalizeChannelName(channel);
return label.toLowerCase().contains( return label.toLowerCase().contains(_searchQuery);
viewState.channelsSearchText.toLowerCase(),
);
}).toList(); }).toList();
int compareByName(Channel a, Channel b) { int compareByName(Channel a, Channel b) {
@@ -646,7 +657,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase()); return nameA.toLowerCase().compareTo(nameB.toLowerCase());
} }
switch (viewState.channelsSortOption) { switch (_sortOption) {
case ChannelSortOption.manual: case ChannelSortOption.manual:
break; break;
case ChannelSortOption.latestMessages: case ChannelSortOption.latestMessages:
+76 -32
View File
@@ -106,10 +106,9 @@ class _ChatScreenState extends State<ChatScreen> {
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have non-empty path data (from device or override)
final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
final hasPathData = effectivePath.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -143,12 +142,25 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final isFloodMode = contact.pathOverride == -1; final isFloodMode = contact.pathOverride == -1;
final isDirectMode = contact.pathOverride == 0;
final activeMode = isFloodMode
? 'flood'
: isDirectMode
? 'direct'
: 'auto';
return PopupMenuButton<String>( return PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route), icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: context.l10n.chat_routingMode, tooltip: context.l10n.chat_routingMode,
onSelected: (mode) async { onSelected: (mode) async {
if (mode == 'flood') { if (mode == 'flood') {
await connector.setPathOverride(contact, pathLen: -1); await connector.setPathOverride(contact, pathLen: -1);
} else if (mode == 'direct') {
await connector.setPathOverride(
contact,
pathLen: 0,
pathBytes: Uint8List(0),
);
} else { } else {
await connector.setPathOverride(contact, pathLen: null); await connector.setPathOverride(contact, pathLen: null);
} }
@@ -161,7 +173,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.auto_mode, Icons.auto_mode,
size: 20, size: 20,
color: !isFloodMode color: activeMode == 'auto'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -169,7 +181,30 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode fontWeight: activeMode == 'auto'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'direct',
child: Row(
children: [
Icon(
Icons.near_me,
size: 20,
color: activeMode == 'direct'
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
context.l10n.chat_direct,
style: TextStyle(
fontWeight: activeMode == 'direct'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -184,7 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
Icon( Icon(
Icons.waves, Icons.waves,
size: 20, size: 20,
color: isFloodMode color: activeMode == 'flood'
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: null, : null,
), ),
@@ -192,7 +227,7 @@ class _ChatScreenState extends State<ChatScreen> {
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode fontWeight: activeMode == 'flood'
? FontWeight.bold ? FontWeight.bold
: FontWeight.normal, : FontWeight.normal,
), ),
@@ -251,7 +286,9 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_sendMessageTo(widget.contact.name), context.l10n.chat_sendMessageTo(
_resolveContact(context.read<MeshCoreConnector>()).name,
),
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
], ],
@@ -269,6 +306,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Auto-scroll to bottom if user is already at bottom // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollController.scrollToBottomIfAtBottom(); _scrollController.scrollToBottomIfAtBottom();
}); });
@@ -293,10 +331,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
final messageIndex = index; final messageIndex = index;
Contact contact = widget.contact; Contact contact = _resolveContact(connector);
final message = reversedMessages[messageIndex]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty message.fourByteRoomContactKey.isEmpty
@@ -314,12 +352,13 @@ class _ChatScreenState extends State<ChatScreen> {
final textScale = context.select<ChatTextScaleService, double>( final textScale = context.select<ChatTextScaleService, double>(
(service) => service.scale, (service) => service.scale,
); );
final resolvedContact = _resolveContact(connector);
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom senderName: resolvedContact.type == advTypeRoom
? "${contact.name} [$fourByteHex]" ? "${contact.name} [$fourByteHex]"
: contact.name, : contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale, textScale: textScale,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -457,7 +496,7 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage(widget.contact, text); connector.sendMessage(_resolveContact(connector), text);
_textController.clear(); _textController.clear();
_textFieldFocusNode.requestFocus(); _textFieldFocusNode.requestFocus();
} }
@@ -654,7 +693,7 @@ class _ChatScreenState extends State<ChatScreen> {
// Set the path override to persist user's choice // Set the path override to persist user's choice
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: pathLength, pathLen: pathLength,
pathBytes: pathBytes, pathBytes: pathBytes,
); );
@@ -663,7 +702,7 @@ class _ChatScreenState extends State<ChatScreen> {
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet( await _notifyPathSet(
connector, connector,
widget.contact, _resolveContact(connector),
pathBytes, pathBytes,
path.hopCount, path.hopCount,
); );
@@ -722,7 +761,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: const TextStyle(fontSize: 11), style: const TextStyle(fontSize: 11),
), ),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(
_resolveContact(connector),
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -750,7 +791,7 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
onTap: () async { onTap: () async {
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: -1, pathLen: -1,
); );
if (!context.mounted) return; if (!context.mounted) return;
@@ -817,7 +858,7 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -986,7 +1027,7 @@ class _ChatScreenState extends State<ChatScreen> {
final currentPathLabel = _currentPathLabel(currentContact); final currentPathLabel = _currentPathLabel(currentContact);
// Filter out the current contact from available contacts // Filter out the current contact from available contacts
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c != widget.contact) .where((c) => c != widget.contact)
.toList(); .toList();
@@ -1005,11 +1046,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (result == null) { if (result == null) {
appLogger.info( return; // Cancelled keep existing path
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
} }
if (!mounted) { if (!mounted) {
@@ -1025,14 +1062,19 @@ class _ChatScreenState extends State<ChatScreen> {
tag: 'ChatScreen', tag: 'ChatScreen',
); );
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, _resolveContact(connector),
pathLen: result.length, pathLen: result.length,
pathBytes: result, pathBytes: result,
); );
appLogger.info('setPathOverride completed', tag: 'ChatScreen'); appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return; if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length); await _notifyPathSet(
connector,
_resolveContact(connector),
result,
result.length,
);
} }
void _openMessagePath(Message message, Contact contact) { void _openMessagePath(Message message, Contact contact) {
@@ -1044,10 +1086,10 @@ class _ChatScreenState extends State<ChatScreen> {
final String senderName; final String senderName;
if (message.isOutgoing) { if (message.isOutgoing) {
senderName = connector.selfName ?? context.l10n.chat_me; senderName = connector.selfName ?? context.l10n.chat_me;
} else if (widget.contact.type == advTypeRoom) { } else if (_resolveContact(connector).type == advTypeRoom) {
senderName = "${contact.name} [$fourByteHex]"; senderName = "${contact.name} [$fourByteHex]";
} else { } else {
senderName = widget.contact.name; senderName = _resolveContact(connector).name;
} }
final pathMessage = ChannelMessage( final pathMessage = ChannelMessage(
senderKey: null, senderKey: null,
@@ -1110,7 +1152,8 @@ class _ChatScreenState extends State<ChatScreen> {
_retryMessage(message); _retryMessage(message);
}, },
), ),
if (widget.contact.type == advTypeRoom) if (_resolveContact(context.read<MeshCoreConnector>()).type ==
advTypeRoom)
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1148,7 +1191,7 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage(widget.contact, message.text); connector.sendMessage(_resolveContact(connector), message.text);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage))); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
@@ -1174,7 +1217,8 @@ class _ChatScreenState extends State<ChatScreen> {
// For room servers, include sender name (like channels) since multiple users // For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom final liveContact = _resolveContact(connector);
final senderName = liveContact.type == advTypeRoom
? senderContact.name ? senderContact.name
: null; : null;
final hash = ReactionHelper.computeReactionHash( final hash = ReactionHelper.computeReactionHash(
@@ -1183,7 +1227,7 @@ class _ChatScreenState extends State<ChatScreen> {
message.text, message.text,
); );
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(_resolveContact(connector), reactionText);
} }
} }
+235 -397
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package:meshcore_open/screens/path_trace_map.dart';
import 'package:meshcore_open/services/notification_service.dart';
import 'package:meshcore_open/utils/app_logger.dart'; import 'package:meshcore_open/utils/app_logger.dart';
import 'package:meshcore_open/widgets/app_bar.dart'; import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -12,9 +13,8 @@ import '../l10n/l10n.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart'; import '../models/contact_group.dart';
import '../services/ui_view_state_service.dart';
import '../utils/contact_search.dart';
import '../storage/contact_group_store.dart'; import '../storage/contact_group_store.dart';
import '../utils/contact_search.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
@@ -48,10 +48,12 @@ class ContactsScreen extends StatefulWidget {
class _ContactsScreenState extends State<ContactsScreen> class _ContactsScreenState extends State<ContactsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
bool _showUnreadOnly = false;
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
final ContactGroupStore _groupStore = ContactGroupStore(); final ContactGroupStore _groupStore = ContactGroupStore();
MeshCoreConnector? _scopeSyncConnector;
List<ContactGroup> _groups = []; List<ContactGroup> _groups = [];
String _loadedGroupScopeKeyHex = '';
Timer? _searchDebounce; Timer? _searchDebounce;
final Set<ContactOperationType> _pendingOperations = {}; final Set<ContactOperationType> _pendingOperations = {};
@@ -61,23 +63,15 @@ class _ContactsScreenState extends State<ContactsScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchController.text = context
.read<UiViewStateService>()
.contactsSearchText;
_loadGroups(); _loadGroups();
_setupFrameListener(); _setupFrameListener();
_clearAdvertNotifications();
} }
@override void _clearAdvertNotifications() {
void didChangeDependencies() {
super.didChangeDependencies();
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
if (!identical(_scopeSyncConnector, connector)) { final contactIds = connector.contacts.map((c) => c.publicKeyHex).toList();
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange); NotificationService().clearAdvertNotifications(contactIds);
_scopeSyncConnector = connector;
_scopeSyncConnector?.addListener(_handleConnectorScopeChange);
}
_handleConnectorScopeChange();
} }
@override @override
@@ -85,67 +79,21 @@ class _ContactsScreenState extends State<ContactsScreen>
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchController.dispose(); _searchController.dispose();
_frameSubscription?.cancel(); _frameSubscription?.cancel();
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
super.dispose(); super.dispose();
} }
void _handleConnectorScopeChange() {
final connector = _scopeSyncConnector;
if (connector == null) return;
_syncGroupScopeIfNeeded(connector);
}
Future<void> _loadGroups() async { Future<void> _loadGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty) {
return;
}
_groupStore.setPublicKeyHex = selfPublicKeyHex;
final groups = await _groupStore.loadGroups(); final groups = await _groupStore.loadGroups();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_loadedGroupScopeKeyHex = selfPublicKeyHex;
_groups = groups; _groups = groups;
_ensureValidSelectedGroup();
}); });
} }
Future<void> _saveGroups() async { Future<void> _saveGroups() async {
final selfPublicKeyHex = context.read<MeshCoreConnector>().selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty) {
return;
}
_groupStore.setPublicKeyHex = selfPublicKeyHex;
await _groupStore.saveGroups(_groups); await _groupStore.saveGroups(_groups);
} }
bool _hasGroupStoreScope(MeshCoreConnector connector) {
return connector.selfPublicKeyHex.isNotEmpty;
}
void _syncGroupScopeIfNeeded(MeshCoreConnector connector) {
final selfPublicKeyHex = connector.selfPublicKeyHex;
if (selfPublicKeyHex.isEmpty ||
selfPublicKeyHex == _loadedGroupScopeKeyHex) {
return;
}
_loadGroups();
}
void _collapseContactsSearch(UiViewStateService viewState) {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear();
viewState.setContactsSearchText('');
viewState.setContactsSearchExpanded(false);
}
void _showGroupsUnavailableMessage(BuildContext context) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.common_loading)));
}
void _setupFrameListener() { void _setupFrameListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater // Listen for incoming text messages from the repeater
@@ -435,166 +383,31 @@ class _ContactsScreenState extends State<ContactsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
ContactGroup? _selectedGroupForName(String selectedGroupName) { Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
if (selectedGroupName == contactsAllGroupsValue) return null;
for (final group in _groups) {
if (group.name == selectedGroupName) return group;
}
return null;
}
void _ensureValidSelectedGroup() {
final viewState = context.read<UiViewStateService>();
if (viewState.contactsSelectedGroupName == contactsAllGroupsValue) return;
final exists = _groups.any(
(group) => group.name == viewState.contactsSelectedGroupName,
);
if (!exists) {
viewState.setContactsSelectedGroupName(contactsAllGroupsValue);
}
}
void _closeDropdownAndRun(BuildContext popupContext, VoidCallback action) {
final route = ModalRoute.of(popupContext);
if (route != null && route.isCurrent) {
Navigator.of(popupContext).pop();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
action();
});
}
Widget _buildFilterButton(
BuildContext context,
UiViewStateService viewState,
) {
return ContactsFilterMenu( return ContactsFilterMenu(
sortOption: viewState.contactsSortOption, sortOption: _sortOption,
typeFilter: viewState.contactsTypeFilter, typeFilter: _typeFilter,
showUnreadOnly: viewState.contactsShowUnreadOnly, showUnreadOnly: _showUnreadOnly,
onSortChanged: (value) { onSortChanged: (value) {
viewState.setContactsSortOption(value); setState(() {
_sortOption = value;
});
}, },
onTypeFilterChanged: (value) { onTypeFilterChanged: (value) {
viewState.setContactsTypeFilter(value); setState(() {
_typeFilter = value;
});
}, },
onUnreadOnlyChanged: (value) { onUnreadOnlyChanged: (value) {
viewState.setContactsShowUnreadOnly(value); setState(() {
_showUnreadOnly = value;
});
}, },
); onNewGroup: () => _showGroupEditor(context, connector.contacts),
}
Widget _buildGroupButton(
BuildContext context,
MeshCoreConnector connector,
UiViewStateService viewState,
List<Contact> contacts,
List<ContactGroup> sortedGroups,
) {
final canManageGroups = _hasGroupStoreScope(connector);
final selectedGroupName =
_selectedGroupForName(viewState.contactsSelectedGroupName)?.name ??
context.l10n.listFilter_all;
final double menuWidth = (MediaQuery.sizeOf(context).width - 16).clamp(
0.0,
double.infinity,
);
return PopupMenuButton<String>(
position: PopupMenuPosition.under,
constraints: BoxConstraints.tightFor(width: menuWidth),
onSelected: (String value) {
viewState.setContactsSelectedGroupName(value);
},
itemBuilder: (menuContext) => [
PopupMenuItem<String>(
value: contactsAllGroupsValue,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(menuContext.l10n.listFilter_all),
IconButton(
tooltip: menuContext.l10n.contacts_newGroup,
icon: const Icon(Icons.group_add, size: 20),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _showGroupEditor(this.context, contacts),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
],
),
),
...sortedGroups.map((group) {
return PopupMenuItem<String>(
value: group.name,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(group.name, overflow: TextOverflow.ellipsis),
),
IconButton(
tooltip: menuContext.l10n.contacts_editGroup,
icon: const Icon(Icons.edit, size: 20),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _showGroupEditor(
this.context,
contacts,
group: group,
),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: menuContext.l10n.contacts_deleteGroup,
icon: const Icon(Icons.delete, size: 20, color: Colors.red),
onPressed: canManageGroups
? () => _closeDropdownAndRun(
menuContext,
() => _confirmDeleteGroup(this.context, group),
)
: () => _closeDropdownAndRun(
menuContext,
() => _showGroupsUnavailableMessage(this.context),
),
),
],
),
);
}),
],
child: SizedBox(
height: 48,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(selectedGroupName, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down),
],
),
),
),
); );
} }
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final viewState = context.watch<UiViewStateService>();
final contacts = connector.contacts; final contacts = connector.contacts;
final shouldShowStartupSpinner = final shouldShowStartupSpinner =
contacts.isEmpty && contacts.isEmpty &&
@@ -616,171 +429,92 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
final filteredAndSorted = _filterAndSortContacts( final filteredAndSorted = _filterAndSortContacts(contacts, connector);
contacts, final filteredGroups = _showUnreadOnly
connector, ? const <ContactGroup>[]
viewState, : _filterAndSortGroups(_groups, contacts);
);
String hintText = ""; String hintText = "";
switch (viewState.contactsTypeFilter) { switch (_typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
hintText = context.l10n.contacts_searchContacts( hintText = context.l10n.contacts_searchContacts(
filteredAndSorted.length, filteredAndSorted.length,
viewState.contactsShowUnreadOnly _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.users: case ContactTypeFilter.users:
hintText = context.l10n.contacts_searchUsers( hintText = context.l10n.contacts_searchUsers(
filteredAndSorted.length, filteredAndSorted.length,
viewState.contactsShowUnreadOnly _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.repeaters: case ContactTypeFilter.repeaters:
hintText = context.l10n.contacts_searchRepeaters( hintText = context.l10n.contacts_searchRepeaters(
filteredAndSorted.length, filteredAndSorted.length,
viewState.contactsShowUnreadOnly _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.rooms: case ContactTypeFilter.rooms:
hintText = context.l10n.contacts_searchRoomServers( hintText = context.l10n.contacts_searchRoomServers(
filteredAndSorted.length, filteredAndSorted.length,
viewState.contactsShowUnreadOnly _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
case ContactTypeFilter.favorites: case ContactTypeFilter.favorites:
hintText = context.l10n.contacts_searchFavorites( hintText = context.l10n.contacts_searchFavorites(
filteredAndSorted.length, filteredAndSorted.length,
viewState.contactsShowUnreadOnly _showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
? " ${context.l10n.contacts_unread}"
: "",
); );
break; break;
} }
final groupsByName = <String, ContactGroup>{};
for (final group in _groups) {
groupsByName.putIfAbsent(group.name, () => group);
}
final sortedGroups = groupsByName.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
final screenWidth = MediaQuery.sizeOf(context).width;
final searchExpandedWidth = (screenWidth * 0.52).clamp(
97.0,
double.infinity,
); // allow expansion up to 52% of screen width, but not less than the collapsed width
final searchCollapsedWidth = (screenWidth * 0.22).clamp(
97.0,
120.0,
); //two 48px icon buttons + 1px divider
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: TextField(
children: [
Expanded(
child: _buildGroupButton(
context,
connector,
viewState,
contacts,
sortedGroups,
),
),
const SizedBox(width: 8),
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: viewState.contactsSearchExpanded
? searchExpandedWidth
: searchCollapsedWidth,
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: viewState.contactsSearchExpanded
? TextField(
controller: _searchController, controller: _searchController,
autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
border: InputBorder.none, 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( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 16,
vertical: 10, vertical: 12,
), ),
), ),
onChanged: (value) { onChanged: (value) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_searchDebounce = Timer( _searchDebounce = Timer(const Duration(milliseconds: 300), () {
const Duration(milliseconds: 300),
() {
if (!mounted) return; if (!mounted) return;
context setState(() {
.read<UiViewStateService>() _searchQuery = value.toLowerCase();
.setContactsSearchText(value); });
});
}, },
);
},
)
: const SizedBox.shrink(),
),
SizedBox(
width: 48,
height: 48,
child: IconButton(
onPressed: () {
if (viewState.contactsSearchExpanded) {
_collapseContactsSearch(viewState);
return;
}
viewState.setContactsSearchExpanded(true);
},
icon: Icon(
viewState.contactsSearchExpanded
? Icons.close
: Icons.search,
),
),
),
Container(
width: 1,
height: 24,
color: Theme.of(context).colorScheme.outlineVariant,
),
SizedBox(
width: 48,
height: 48,
child: _buildFilterButton(context, viewState),
),
],
),
),
),
],
), ),
), ),
Expanded( Expanded(
child: filteredAndSorted.isEmpty child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -788,7 +522,7 @@ class _ContactsScreenState extends State<ContactsScreen>
Icon(Icons.search_off, size: 64, color: Colors.grey[400]), Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
viewState.contactsShowUnreadOnly _showUnreadOnly
? context.l10n.contacts_noUnreadContacts ? context.l10n.contacts_noUnreadContacts
: context.l10n.contacts_noContactsFound, : context.l10n.contacts_noContactsFound,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(fontSize: 16, color: Colors.grey[600]),
@@ -799,9 +533,14 @@ class _ContactsScreenState extends State<ContactsScreen>
: RefreshIndicator( : RefreshIndicator(
onRefresh: () => connector.getContacts(), onRefresh: () => connector.getContacts(),
child: ListView.builder( child: ListView.builder(
itemCount: filteredAndSorted.length, itemCount: filteredGroups.length + filteredAndSorted.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final contact = filteredAndSorted[index]; if (index < filteredGroups.length) {
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact =
filteredAndSorted[index - filteredGroups.length];
final unreadCount = connector.getUnreadCountForContact( final unreadCount = connector.getUnreadCountForContact(
contact, contact,
); );
@@ -822,26 +561,55 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
List<ContactGroup> _filterAndSortGroups(
List<ContactGroup> groups,
List<Contact> contacts,
) {
final query = _searchQuery.trim().toLowerCase();
final contactsByKey = <String, Contact>{};
for (final contact in contacts) {
contactsByKey[contact.publicKeyHex] = contact;
}
final filtered = groups
.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && matchesContactQuery(contact, query)) {
return true;
}
}
return false;
})
.where((group) {
if (_typeFilter == ContactTypeFilter.all) return true;
// Groups don't have a favorite flag, so hide them under favorites filter
if (_typeFilter == ContactTypeFilter.favorites) return false;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && _matchesTypeFilter(contact)) return true;
}
return false;
})
.toList();
filtered.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return filtered;
}
List<Contact> _filterAndSortContacts( List<Contact> _filterAndSortContacts(
List<Contact> contacts, List<Contact> contacts,
MeshCoreConnector connector, MeshCoreConnector connector,
UiViewStateService viewState,
) { ) {
var filtered = contacts.where((contact) { var filtered = contacts.where((contact) {
if (viewState.contactsSearchText.isEmpty) return true; if (_searchQuery.isEmpty) return true;
return matchesContactQuery(contact, viewState.contactsSearchText); return matchesContactQuery(contact, _searchQuery);
}).toList(); }).toList();
final selectedGroup = _selectedGroupForName(
viewState.contactsSelectedGroupName,
);
if (selectedGroup != null) {
final memberKeys = selectedGroup.memberKeys.toSet();
filtered = filtered
.where((contact) => memberKeys.contains(contact.publicKeyHex))
.toList();
}
// Filter out own node from the list // Filter out own node from the list
if (connector.selfPublicKey != null) { if (connector.selfPublicKey != null) {
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!); final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
@@ -850,22 +618,17 @@ class _ContactsScreenState extends State<ContactsScreen>
}).toList(); }).toList();
} }
if (viewState.contactsTypeFilter != ContactTypeFilter.all) { if (_typeFilter != ContactTypeFilter.all) {
filtered = filtered filtered = filtered.where(_matchesTypeFilter).toList();
.where(
(contact) =>
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
)
.toList();
} }
if (viewState.contactsShowUnreadOnly) { if (_showUnreadOnly) {
filtered = filtered.where((contact) { filtered = filtered.where((contact) {
return connector.getUnreadCountForContact(contact) > 0; return connector.getUnreadCountForContact(contact) > 0;
}).toList(); }).toList();
} }
switch (viewState.contactsSortOption) { switch (_sortOption) {
case ContactSortOption.lastSeen: case ContactSortOption.lastSeen:
filtered.sort( filtered.sort(
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
@@ -894,8 +657,8 @@ class _ContactsScreenState extends State<ContactsScreen>
return filtered; return filtered;
} }
bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) { bool _matchesTypeFilter(Contact contact) {
switch (typeFilter) { switch (_typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
case ContactTypeFilter.favorites: case ContactTypeFilter.favorites:
@@ -916,6 +679,57 @@ class _ContactsScreenState extends State<ContactsScreen>
: contact.lastSeen; : contact.lastSeen;
} }
Widget _buildGroupTile(
BuildContext context,
ContactGroup group,
List<Contact> contacts,
) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(context, memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.group, color: Colors.white, size: 20),
),
title: Text(group.name),
subtitle: Text(subtitle),
trailing: Text(
memberContacts.length.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
onTap: () => _showGroupOptions(context, group, contacts),
onLongPress: () => _showGroupOptions(context, group, contacts),
);
}
List<Contact> _resolveGroupContacts(
ContactGroup group,
List<Contact> contacts,
) {
final byKey = <String, Contact>{};
for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact;
}
final resolved = <Contact>[];
for (final key in group.memberKeys) {
final contact = byKey[key];
if (contact != null) {
resolved.add(contact);
}
}
resolved.sort(
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
);
return resolved;
}
String _formatGroupMembers(BuildContext context, List<Contact> members) {
if (members.isEmpty) return context.l10n.contacts_noMembers;
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
}
void _openChat(BuildContext context, Contact contact) { void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater // Check if this is a repeater
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
@@ -993,11 +807,58 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
void _confirmDeleteGroup(BuildContext context, ContactGroup group) { void _showGroupOptions(
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) { BuildContext context,
_showGroupsUnavailableMessage(context); ContactGroup group,
return; List<Contact> contacts,
) {
final members = _resolveGroupContacts(group, contacts);
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text(context.l10n.contacts_editGroup),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
context.l10n.contacts_deleteGroup,
style: const TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
},
),
if (members.isNotEmpty) const Divider(),
...members.map((member) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(member.name),
subtitle: Text(member.typeLabel),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, member);
},
);
}),
],
),
),
),
);
} }
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -1013,7 +874,6 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
setState(() { setState(() {
_groups.removeWhere((g) => g.name == group.name); _groups.removeWhere((g) => g.name == group.name);
_ensureValidSelectedGroup();
}); });
await _saveGroups(); await _saveGroups();
}, },
@@ -1032,10 +892,6 @@ class _ContactsScreenState extends State<ContactsScreen>
List<Contact> contacts, { List<Contact> contacts, {
ContactGroup? group, ContactGroup? group,
}) { }) {
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
final isEditing = group != null; final isEditing = group != null;
final nameController = TextEditingController(text: group?.name ?? ''); final nameController = TextEditingController(text: group?.name ?? '');
final selectedKeys = <String>{...group?.memberKeys ?? []}; final selectedKeys = <String>{...group?.memberKeys ?? []};
@@ -1062,10 +918,6 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -1091,7 +943,8 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded( SizedBox(
height: 240,
child: filteredContacts.isEmpty child: filteredContacts.isEmpty
? Center( ? Center(
child: Text( child: Text(
@@ -1114,9 +967,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (value == true) { if (value == true) {
selectedKeys.add(contact.publicKeyHex); selectedKeys.add(contact.publicKeyHex);
} else { } else {
selectedKeys.remove( selectedKeys.remove(contact.publicKeyHex);
contact.publicKeyHex,
);
} }
}); });
}, },
@@ -1127,7 +978,6 @@ class _ContactsScreenState extends State<ContactsScreen>
], ],
), ),
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext), onPressed: () => Navigator.pop(dialogContext),
@@ -1144,15 +994,6 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
return; return;
} }
if (name.toLowerCase() ==
contactsAllGroupsValue.toLowerCase()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.contacts_groupNameReserved),
),
);
return;
}
final exists = _groups.any((g) { final exists = _groups.any((g) {
if (isEditing && g.name == group.name) return false; if (isEditing && g.name == group.name) return false;
return g.name.toLowerCase() == name.toLowerCase(); return g.name.toLowerCase() == name.toLowerCase();
@@ -1168,21 +1009,15 @@ class _ContactsScreenState extends State<ContactsScreen>
return; return;
} }
setState(() { setState(() {
final viewState = context.read<UiViewStateService>();
if (isEditing) { if (isEditing) {
final index = _groups.indexWhere( final index = _groups.indexWhere(
(g) => g.name == group.name, (g) => g.name == group.name,
); );
if (index != -1) { if (index != -1) {
final wasSelected =
viewState.contactsSelectedGroupName == group.name;
_groups[index] = ContactGroup( _groups[index] = ContactGroup(
name: name, name: name,
memberKeys: selectedKeys.toList(), memberKeys: selectedKeys.toList(),
); );
if (wasSelected) {
viewState.setContactsSelectedGroupName(name);
}
} }
} else { } else {
_groups.add( _groups.add(
@@ -1191,9 +1026,7 @@ class _ContactsScreenState extends State<ContactsScreen>
memberKeys: selectedKeys.toList(), memberKeys: selectedKeys.toList(),
), ),
); );
viewState.setContactsSelectedGroupName(name);
} }
_ensureValidSelectedGroup();
}); });
await _saveGroups(); await _saveGroups();
if (dialogContext.mounted) { if (dialogContext.mounted) {
@@ -1231,7 +1064,7 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[ if (isRepeater) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? Text(context.l10n.contacts_pathTrace) ? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping), : Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
@@ -1239,10 +1072,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_repeaterPathTrace ? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing, : context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact,
), ),
), ),
); );
@@ -1267,10 +1102,12 @@ class _ContactsScreenState extends State<ContactsScreen>
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: contact.pathLength > 0 title: contact.pathBytesForDisplay.isNotEmpty
? context.l10n.contacts_roomPathTrace ? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing, : context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: contact.pathBytesForDisplay.isNotEmpty,
targetContact: contact,
), ),
), ),
); );
@@ -1312,7 +1149,8 @@ class _ContactsScreenState extends State<ContactsScreen>
title: context.l10n.contacts_pathTraceTo( title: context.l10n.contacts_pathTraceTo(
contact.name, contact.name,
), ),
path: contact.traceRouteBytes ?? Uint8List(0), path: contact.pathBytesForDisplay,
flipPathAround: true,
targetContact: contact, targetContact: contact,
), ),
), ),
+20 -13
View File
@@ -137,10 +137,7 @@ class _MapScreenState extends State<MapScreen> {
builder: (context, connector, settingsService, pathHistory, child) { builder: (context, connector, settingsService, pathHistory, child) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final settings = settingsService.settings; final settings = settingsService.settings;
final allContacts = <Contact>[ final allContacts = connector.allContacts;
...connector.contacts,
...connector.discoveredContacts.where((c) => !c.isActive),
];
final contacts = settings.mapShowDiscoveryContacts final contacts = settings.mapShowDiscoveryContacts
? allContacts ? allContacts
@@ -179,20 +176,13 @@ class _MapScreenState extends State<MapScreen> {
// Filter by location // Filter by location
final contactsWithLocation = filteredByKeyPrefix.where((c) { final contactsWithLocation = filteredByKeyPrefix.where((c) {
if (!c.hasLocation) { return c.hasLocation;
return false;
}
return _checkLocationPlausibility(c.latitude!, c.longitude!);
}).toList(); }).toList();
// All contacts with a known location used as anchors regardless of // All contacts with a known location used as anchors regardless of
// time/key-prefix filters so that repeaters are always available. // time/key-prefix filters so that repeaters are always available.
final allContactsWithLocation = allContacts final allContactsWithLocation = allContacts
.where( .where((c) => c.hasLocation)
(c) =>
c.hasLocation &&
_checkLocationPlausibility(c.latitude!, c.longitude!),
)
.toList(); .toList();
// Compute guessed locations with caching // Compute guessed locations with caching
@@ -1509,6 +1499,23 @@ class _MapScreenState extends State<MapScreen> {
); );
}, },
), ),
ListTile(
leading: const Icon(Icons.my_location),
title: Text(context.l10n.map_setAsMyLocation),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
final successMsg = context.l10n.settings_locationUpdated;
Navigator.pop(sheetContext);
if (!connector.isConnected) return;
await connector.setNodeLocation(
lat: position.latitude,
lon: position.longitude,
);
await connector.refreshDeviceInfo();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
},
),
ListTile( ListTile(
leading: const Icon(Icons.close), leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel), title: Text(context.l10n.common_cancel),
+1 -4
View File
@@ -124,10 +124,7 @@ class _NeighborsScreenState extends State<NeighborsScreen> {
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) { void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
final contacts = <Contact>[ final contacts = connector.allContacts;
...connector.contacts,
...connector.discoveredContacts,
];
try { try {
final neighborCount = buffer.readUInt16LE(); final neighborCount = buffer.readUInt16LE();
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE()); final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
+78 -33
View File
@@ -52,8 +52,8 @@ class PathTraceMapScreen extends StatefulWidget {
final String title; final String title;
final Uint8List path; final Uint8List path;
final int? repeaterId; final int? repeaterId;
final bool flipPathRound; final bool flipPathAround;
final bool reversePathRound; final bool reversePathAround;
final Contact? targetContact; final Contact? targetContact;
const PathTraceMapScreen({ const PathTraceMapScreen({
@@ -61,8 +61,8 @@ class PathTraceMapScreen extends StatefulWidget {
required this.title, required this.title,
required this.path, required this.path,
this.repeaterId, this.repeaterId,
this.flipPathRound = false, this.flipPathAround = false,
this.reversePathRound = false, this.reversePathAround = false,
this.targetContact, this.targetContact,
}); });
@@ -93,6 +93,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
ValueKey<String> _mapKey = const ValueKey('initial'); ValueKey<String> _mapKey = const ValueKey('initial');
double _pathDistanceMeters = 0.0; double _pathDistanceMeters = 0.0;
bool _showNodeLabels = true; bool _showNodeLabels = true;
Contact? _targetContact;
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes return pathBytes
@@ -158,21 +159,16 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
}); });
} }
final Uint8List path; final pathTmp = widget.reversePathAround
Uint8List pathTmp = widget.reversePathRound
? Uint8List.fromList(widget.path.reversed.toList()) ? Uint8List.fromList(widget.path.reversed.toList())
: widget.path; : widget.path;
if (widget.flipPathRound) { final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
path = buildPath(pathTmp);
} else {
path = pathTmp;
}
appLogger.info( appLogger.info(
'Initiating path trace with path: ${_formatPathPrefixes(path)}', 'Initiating path trace with path: ${_formatPathPrefixes(path)}',
tag: 'PathTraceMapScreen', tag: 'PathTraceMapScreen',
noNotify: !mounted,
); );
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -263,10 +259,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
.toList(); .toList();
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
final contacts = <Contact>[ final contacts = connector.allContacts;
...connector.contacts,
...connector.discoveredContacts,
];
contacts.where((c) => c.type != advTypeChat).forEach((repeater) { contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var repeaterData in pathData) { for (var repeaterData in pathData) {
if (listEquals( if (listEquals(
@@ -312,18 +305,21 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Compute endpoint position for the target contact. // Compute endpoint position for the target contact.
LatLng? targetPos; LatLng? targetPos;
bool targetGuessed = false; bool targetGuessed = false;
final target = widget.targetContact; _targetContact = widget.targetContact;
if (target != null) {
if (target.hasLocation) { if (_targetContact != null) {
targetPos = LatLng(target.latitude!, target.longitude!); final tc = _targetContact!;
} else if (pathData.isNotEmpty) { if (tc.hasLocation) {
targetPos = LatLng(tc.latitude!, tc.longitude!);
} else if (widget.path.length > 1) {
// Infer from the last hop: average GPS contacts sharing that hop. // Infer from the last hop: average GPS contacts sharing that hop.
// For a round-trip path (flipPathRound), the target-side hop sits // For a round-trip path (flipPathAround/reversePathAround), the target-side hop
// in the middle of the symmetric sequence; .last is the local side. // sits in the middle of the symmetric sequence; .last is the local side.
final lastHop = (widget.flipPathRound && pathData.length > 1) final lastHop = widget.reversePathAround
? pathData[(pathData.length - 1) ~/ 2] ? widget.path.first
: pathData.last; : widget.path.last;
final peers = connector.contacts
final peers = connector.allContacts
.where( .where(
(c) => (c) =>
c.hasLocation && c.hasLocation &&
@@ -339,12 +335,34 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
peers.map((c) => c.longitude!).reduce((a, b) => a + b) / peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
peers.length; peers.length;
const offsetDeg = 0.003; const offsetDeg = 0.003;
final angle = (target.publicKey[1] / 255.0) * 2 * pi; final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng( targetPos = LatLng(
lat + offsetDeg * cos(angle), lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle), lon + offsetDeg * sin(angle),
); );
targetGuessed = true; targetGuessed = true;
} else if (inferredPositions.containsKey(lastHop)) {
final lat = inferredPositions[lastHop]!.latitude;
final lon = inferredPositions[lastHop]!.longitude;
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
lat + offsetDeg * cos(angle),
lon + offsetDeg * sin(angle),
);
targetGuessed = true;
} else {
// As a last resort, just place it at the same position as the last hop.
final contact = pathContacts[lastHop];
if (contact != null && contact.hasLocation) {
const offsetDeg = 0.003;
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
targetPos = LatLng(
contact.latitude! + offsetDeg * cos(angle),
contact.longitude! + offsetDeg * sin(angle),
);
targetGuessed = true;
}
} }
} }
} }
@@ -353,7 +371,12 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
_points = <LatLng>[]; _points = <LatLng>[];
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!)); _points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
int hopLast = 0;
int hopLastLast = 0;
for (final hop in _traceData!.pathData) { for (final hop in _traceData!.pathData) {
if (hop == hopLastLast && widget.flipPathAround) {
break; //skip duplicate hops in round-trip paths
}
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
if (contact != null && contact.hasLocation) { if (contact != null && contact.hasLocation) {
_points.add(LatLng(contact.latitude!, contact.longitude!)); _points.add(LatLng(contact.latitude!, contact.longitude!));
@@ -361,8 +384,14 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
final inferred = inferredPositions[hop]; final inferred = inferredPositions[hop];
if (inferred != null) _points.add(inferred); if (inferred != null) _points.add(inferred);
} }
hopLastLast = hopLast;
hopLast = hop;
}
if (targetPos != null) {
if (_targetContact != null && _targetContact!.type == advTypeChat) {
_points.add(targetPos);
}
} }
if (targetPos != null) _points.add(targetPos);
_polylines = _points.length > 1 _polylines = _points.length > 1
? [ ? [
Polyline( Polyline(
@@ -451,7 +480,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
], ],
), ),
), ),
if (_hasData) _buildMapPathTrace(context, tileCache), if (_hasData)
_buildMapPathTrace(context, tileCache, _targetContact),
if (_points.isEmpty && if (_points.isEmpty &&
!_hasData && !_hasData &&
!_isLoading && !_isLoading &&
@@ -480,17 +510,28 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
List<Marker> _buildHopMarkers( List<Marker> _buildHopMarkers(
List<int> pathData, { List<int> pathData, {
required bool showLabels, required bool showLabels,
required Contact? target,
}) { }) {
final markers = <Marker>[]; final markers = <Marker>[];
int hopLast = 0;
int hopLastLast = 0;
for (final hop in pathData) { for (final hop in pathData) {
final contact = _traceData!.pathContacts[hop]; final contact = _traceData!.pathContacts[hop];
final inferred = _inferredHopPositions[hop]; final inferred = _inferredHopPositions[hop];
final hasGps = contact != null && contact.hasLocation; final hasGps = contact != null && contact.hasLocation;
if (!hasGps && inferred == null) continue; if (hop == hopLastLast && widget.flipPathAround) {
continue; //skip duplicate hops in round-trip paths
}
if (!hasGps && inferred == null) {
hopLastLast = hopLast;
hopLast = hop;
continue; //skip hops with no GPS and no inferred position
}
final point = hasGps final point = hasGps
? LatLng(contact.latitude!, contact.longitude!) ? LatLng(contact.latitude!, contact.longitude!)
: inferred!; : inferred!;
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase(); final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
markers.add( markers.add(
Marker( Marker(
point: point, point: point,
@@ -532,6 +573,8 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
), ),
); );
} }
hopLastLast = hopLast;
hopLast = hop;
} }
final selfLat = context.read<MeshCoreConnector>().selfLatitude; final selfLat = context.read<MeshCoreConnector>().selfLatitude;
@@ -581,9 +624,9 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
// Add target contact endpoint marker. // Add target contact endpoint marker.
final targetPos = _targetContactPosition; final targetPos = _targetContactPosition;
if (targetPos != null) { if (targetPos != null && target != null && target.type == advTypeChat) {
final isGuessed = _targetContactIsGuessed; final isGuessed = _targetContactIsGuessed;
final targetName = widget.targetContact?.name ?? '?'; final targetName = target.name;
markers.add( markers.add(
Marker( Marker(
point: targetPos, point: targetPos,
@@ -719,6 +762,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
Widget _buildMapPathTrace( Widget _buildMapPathTrace(
BuildContext context, BuildContext context,
MapTileCacheService tileCache, MapTileCacheService tileCache,
Contact? target,
) { ) {
return FlutterMap( return FlutterMap(
key: _mapKey, key: _mapKey,
@@ -757,6 +801,7 @@ class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
markers: _buildHopMarkers( markers: _buildHopMarkers(
_traceData!.pathData, _traceData!.pathData,
showLabels: _showNodeLabels, showLabels: _showNodeLabels,
target: target,
), ),
), ),
], ],
+15 -2
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
import '../utils/platform_info.dart'; import '../utils/platform_info.dart';
import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
@@ -27,8 +28,14 @@ class _TcpScreenState extends State<TcpScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_hostController = TextEditingController(); _hostController = TextEditingController(
_portController = TextEditingController(text: '5000'); text: context.read<AppSettingsService>().settings.tcpServerAddress,
);
_portController = TextEditingController(
text: context.read<AppSettingsService>().settings.tcpServerPort > 0
? context.read<AppSettingsService>().settings.tcpServerPort.toString()
: '',
);
_connector = context.read<MeshCoreConnector>(); _connector = context.read<MeshCoreConnector>();
_connectionListener = () { _connectionListener = () {
@@ -39,6 +46,12 @@ class _TcpScreenState extends State<TcpScreen> {
if (_connector.state == MeshCoreConnectionState.connected && if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isTcpTransportConnected && _connector.isTcpTransportConnected &&
!_navigatedToContacts) { !_navigatedToContacts) {
context.read<AppSettingsService>().setTcpServerAddress(
_hostController.text,
);
context.read<AppSettingsService>().setTcpServerPort(
int.tryParse(_portController.text) ?? 0,
);
_navigatedToContacts = true; _navigatedToContacts = true;
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ContactsScreen()), MaterialPageRoute(builder: (_) => const ContactsScreen()),
+9 -6
View File
@@ -51,6 +51,7 @@ class AppDebugLogService extends ChangeNotifier {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (!_enabled && !kDebugMode) return; if (!_enabled && !kDebugMode) return;
if (!_enabled) { if (!_enabled) {
@@ -72,22 +73,24 @@ class AppDebugLogService extends ChangeNotifier {
_entries.removeRange(0, _entries.length - maxEntries); _entries.removeRange(0, _entries.length - maxEntries);
} }
if (!noNotify) {
notifyListeners(); notifyListeners();
}
// Also print to console for development // Also print to console for development
debugPrint('[$tag] $message'); debugPrint('[$tag] $message');
} }
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.info); log(message, tag: tag, level: AppDebugLogLevel.info, noNotify: noNotify);
} }
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.warning); log(message, tag: tag, level: AppDebugLogLevel.warning, noNotify: noNotify);
} }
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
log(message, tag: tag, level: AppDebugLogLevel.error); log(message, tag: tag, level: AppDebugLogLevel.error, noNotify: noNotify);
} }
void clear() { void clear() {
+8
View File
@@ -182,4 +182,12 @@ class AppSettingsService extends ChangeNotifier {
..remove(channelName); ..remove(channelName);
await updateSettings(_settings.copyWith(mutedChannels: updated)); await updateSettings(_settings.copyWith(mutedChannels: updated));
} }
Future<void> setTcpServerAddress(String value) async {
await updateSettings(_settings.copyWith(tcpServerAddress: value));
}
Future<void> setTcpServerPort(int value) async {
await updateSettings(_settings.copyWith(tcpServerPort: value));
}
} }
+1 -1
View File
@@ -65,7 +65,7 @@ class ChatTextScaleService extends ChangeNotifier {
void _commitScale() { void _commitScale() {
_saveTimer?.cancel(); _saveTimer?.cancel();
unawaited(PrefsManager.instance.setDouble(_prefKey, _scale)); PrefsManager.instance.setDouble(_prefKey, _scale);
} }
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
+133 -37
View File
@@ -44,6 +44,12 @@ class MessageRetryService extends ChangeNotifier {
[]; // Rolling buffer of recent ACK hashes []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, List<String>> _sendQueue =
{}; // contactPubKeyHex ordered list of messageIds awaiting send
final Set<String> _activeMessages =
{}; // messageIds currently in-flight (sent/retrying)
final Set<String> _resolvedMessages =
{}; // messageIds already resolved (prevents double _onMessageResolved)
final Map<String, String> _expectedHashToMessageId = final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
@@ -156,7 +162,49 @@ class MessageRetryService extends ChangeNotifier {
_addMessageCallback!(contact.publicKeyHex, message); _addMessageCallback!(contact.publicKeyHex, message);
} }
await _attemptSend(messageId); // Queue per contact only one message in-flight at a time to avoid
// overflowing the firmware's 8-entry expected_ack_table.
final contactKey = contact.publicKeyHex;
_sendQueue[contactKey] ??= [];
_sendQueue[contactKey]!.add(messageId);
if (!_activeMessages.any(
(id) => _pendingContacts[id]?.publicKeyHex == contactKey,
)) {
_sendNextForContact(contactKey);
}
}
void _sendNextForContact(String contactKey) {
final queue = _sendQueue[contactKey];
if (queue == null) return;
// Drain stale entries iteratively instead of recursing.
while (queue.isNotEmpty) {
final messageId = queue.removeAt(0);
if (_pendingMessages.containsKey(messageId)) {
_activeMessages.add(messageId);
_attemptSend(messageId).catchError((e) {
debugPrint('_attemptSend threw for $messageId: $e');
final msg = _pendingMessages[messageId];
if (msg != null) {
final failed = msg.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failed;
_updateMessageCallback?.call(failed);
}
_onMessageResolved(messageId, contactKey);
});
return;
}
// Message was cancelled/cleaned up while queued try next
}
}
void _onMessageResolved(String messageId, String contactKey) {
if (_resolvedMessages.contains(messageId)) return;
_resolvedMessages.add(messageId);
_activeMessages.remove(messageId);
_sendNextForContact(contactKey);
} }
Future<void> _attemptSend(String messageId) async { Future<void> _attemptSend(String messageId) async {
@@ -169,13 +217,11 @@ class MessageRetryService extends ChangeNotifier {
// Use the path that was captured when the message was first sent // Use the path that was captured when the message was first sent
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path
debugPrint( debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}', 'Setting flood mode for retry attempt ${message.retryCount}',
); );
_clearContactPathCallback!(contact); await _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes : message.pathBytes
@@ -192,6 +238,24 @@ class MessageRetryService extends ChangeNotifier {
} }
} }
// Re-validate after async gap a timer or ACK could have resolved/retried
// this message while we were awaiting the path callback.
final currentMessage = _pendingMessages[messageId];
if (currentMessage == null || _resolvedMessages.contains(messageId)) {
debugPrint(
'_attemptSend: message $messageId resolved during path sync, aborting',
);
return;
}
// If the message was retried by a timer during our await, the retryCount
// will have advanced. Only proceed if it still matches the attempt we started.
if (currentMessage.retryCount != message.retryCount) {
debugPrint(
'_attemptSend: message $messageId retryCount changed during path sync, aborting',
);
return;
}
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
@@ -231,6 +295,15 @@ class MessageRetryService extends ChangeNotifier {
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds); _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
} else {
// No send callback message would be stuck forever. Fail it immediately.
debugPrint(
'_attemptSend: no sendMessageCallback, failing message $messageId',
);
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
_updateMessageCallback?.call(failedMessage);
_onMessageResolved(messageId, contact.publicKeyHex);
} }
} }
@@ -281,6 +354,7 @@ class MessageRetryService extends ChangeNotifier {
} }
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added) // FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
// Only match within a single contact's queue to avoid cross-contact mismatches.
if (messageId == null && allowQueueFallback) { if (messageId == null && allowQueueFallback) {
_debugLogService?.warn( _debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
@@ -290,13 +364,16 @@ class MessageRetryService extends ChangeNotifier {
'Hash-based match failed for $ackHashHex, falling back to queue-based matching', 'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
); );
for (var entry in _pendingMessageQueuePerContact.entries) { // Search all contact queues so concurrent chats don't miss matches.
final queuesToSearch = _pendingMessageQueuePerContact;
for (var entry in queuesToSearch.entries) {
final contactKey = entry.key; final contactKey = entry.key;
final queue = entry.value; final queue = entry.value;
if (queue.isNotEmpty) { // Drain stale entries until we find a valid one or exhaust the queue.
while (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0); final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
@@ -304,21 +381,10 @@ class MessageRetryService extends ChangeNotifier {
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey', 'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
); );
break; break;
} else { }
debugPrint('Dequeued stale message $candidateMessageId - skipping'); debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
}
} }
if (messageId != null) break;
} }
} }
@@ -463,22 +529,7 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Max retries reached - mark as failed // Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed); final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages[messageId] = failedMessage;
// Move ACK hashes to history before removing
_moveAckHashesToHistory(messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
// Check if we should clear the path on max retry // Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true && if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
@@ -499,6 +550,30 @@ class MessageRetryService extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
// Message is done retrying send next queued message for this contact
_onMessageResolved(messageId, contact.publicKeyHex);
// Keep message in pending maps for 30s grace period so late ACKs
// can still match and update the message to delivered.
_timeoutTimers[messageId] = Timer(const Duration(seconds: 30), () {
_moveAckHashesToHistory(messageId);
// Clean up ALL hash mappings for this message
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == messageId,
);
_expectedHashToMessageId.removeWhere((_, msgId) => msgId == messageId);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers.remove(messageId);
_resolvedMessages.remove(messageId);
final contactKey = contact.publicKeyHex;
_pendingMessageQueuePerContact[contactKey]?.remove(messageId);
if (_pendingMessageQueuePerContact[contactKey]?.isEmpty ?? false) {
_pendingMessageQueuePerContact.remove(contactKey);
}
});
} }
} }
@@ -594,7 +669,15 @@ class MessageRetryService extends ChangeNotifier {
} }
if (matchedMessageId != null) { if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!; final message = _pendingMessages[matchedMessageId];
if (message == null) {
// Message was already cleaned up (e.g. grace period expired)
_ackHashToMessageId.remove(ackHashHex);
debugPrint(
'ACK matched $matchedMessageId but message already cleaned up',
);
return;
}
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
@@ -616,12 +699,21 @@ class MessageRetryService extends ChangeNotifier {
tripTimeMs: tripTimeMs, tripTimeMs: tripTimeMs,
); );
// Clean up ALL hash mappings for this message (from all retry attempts)
_ackHashToMessageId.removeWhere(
(_, mapping) => mapping.messageId == matchedMessageId,
);
_expectedHashToMessageId.removeWhere(
(_, msgId) => msgId == matchedMessageId,
);
// Move ACK hashes to history before removing // Move ACK hashes to history before removing
_moveAckHashesToHistory(matchedMessageId); _moveAckHashesToHistory(matchedMessageId);
_pendingMessages.remove(matchedMessageId); _pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId); _pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId); _pendingPathSelections.remove(matchedMessageId);
_resolvedMessages.remove(matchedMessageId);
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
@@ -646,6 +738,7 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
_onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
notifyListeners(); notifyListeners();
@@ -783,6 +876,9 @@ class MessageRetryService extends ChangeNotifier {
_ackHistory.clear(); _ackHistory.clear();
_ackHashToMessageId.clear(); _ackHashToMessageId.clear();
_pendingMessageQueuePerContact.clear(); _pendingMessageQueuePerContact.clear();
_sendQueue.clear();
_activeMessages.clear();
_resolvedMessages.clear();
super.dispose(); super.dispose();
} }
} }
+58 -1
View File
@@ -232,7 +232,9 @@ class NotificationService {
try { try {
await _notifications.show( await _notifications.show(
id: contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch, id: contactId != null
? 'advert:$contactId'.hashCode
: DateTime.now().millisecondsSinceEpoch,
title: _l10n.notification_newTypeDiscovered(contactType), title: _l10n.notification_newTypeDiscovered(contactType),
body: contactName, body: contactName,
notificationDetails: notificationDetails, notificationDetails: notificationDetails,
@@ -331,6 +333,61 @@ class NotificationService {
await _notifications.cancel(id: id); await _notifications.cancel(id: id);
} }
/// Cancel the notification for a specific contact and update the app badge.
Future<void> clearContactNotification(
String contactId,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: contactId.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel the notification for a specific channel and update the app badge.
Future<void> clearChannelNotification(
int channelIndex,
int totalUnreadCount,
) async {
if (!await _ensureInitialized()) return;
await _notifications.cancel(id: channelIndex.hashCode);
await _updateBadge(totalUnreadCount);
}
/// Cancel advert notifications for the given contact public key hexes.
Future<void> clearAdvertNotifications(List<String> contactIds) async {
if (!await _ensureInitialized()) return;
for (final id in contactIds) {
await _notifications.cancel(id: 'advert:$id'.hashCode);
}
}
Future<void> _updateBadge(int count) async {
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
// On Apple platforms, set the badge number directly via a silent update.
final darwinDetails = DarwinNotificationDetails(
presentAlert: false,
presentSound: false,
presentBadge: true,
badgeNumber: count,
);
final details = NotificationDetails(
iOS: darwinDetails,
macOS: darwinDetails,
);
// Use a fixed ID so each update replaces the previous one.
await _notifications.show(
id: 'badge_update'.hashCode,
title: null,
body: null,
notificationDetails: details,
);
// Immediately cancel the silent notification so it doesn't appear in tray.
await _notifications.cancel(id: 'badge_update'.hashCode);
}
// On Android, badge count is derived from active notifications,
// so cancelling the specific notification above is sufficient.
}
// //
// Public notification methods (rate limiting is enforced automatically) // Public notification methods (rate limiting is enforced automatically)
// //
-154
View File
@@ -1,154 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../storage/prefs_manager.dart';
import '../utils/contact_search.dart';
const String contactsAllGroupsValue = '__all__';
enum ChannelSortOption { manual, name, latestMessages, unread }
class UiViewStateService extends ChangeNotifier {
static const _keyContactsSelectedGroupName = 'ui_contacts_selected_group';
static const _keyContactsSortOption = 'ui_contacts_sort_option';
static const _keyContactsShowUnreadOnly = 'ui_contacts_show_unread_only';
static const _keyContactsTypeFilter = 'ui_contacts_type_filter';
static const _keyChannelsSortOption = 'ui_channels_sort_option';
static const _keyChannelsSortIndexLegacy = 'ui_channels_sort_index';
String _contactsSelectedGroupName = contactsAllGroupsValue;
String _contactsSearchText = '';
bool _contactsSearchExpanded = false;
ContactSortOption _contactsSortOption = ContactSortOption.lastSeen;
bool _contactsShowUnreadOnly = false;
ContactTypeFilter _contactsTypeFilter = ContactTypeFilter.all;
String _channelsSearchText = '';
ChannelSortOption _channelsSortOption = ChannelSortOption.manual;
String get contactsSelectedGroupName => _contactsSelectedGroupName;
String get contactsSearchText => _contactsSearchText;
bool get contactsSearchExpanded => _contactsSearchExpanded;
ContactSortOption get contactsSortOption => _contactsSortOption;
bool get contactsShowUnreadOnly => _contactsShowUnreadOnly;
ContactTypeFilter get contactsTypeFilter => _contactsTypeFilter;
String get channelsSearchText => _channelsSearchText;
ChannelSortOption get channelsSortOption => _channelsSortOption;
Future<void> initialize() async {
final prefs = PrefsManager.instance;
final selectedGroupName = prefs.getString(_keyContactsSelectedGroupName);
if (selectedGroupName != null && selectedGroupName.isNotEmpty) {
_contactsSelectedGroupName = selectedGroupName;
}
final sortStr = prefs.getString(_keyContactsSortOption);
if (sortStr != null) {
_contactsSortOption = ContactSortOption.values.firstWhere(
(e) => e.name == sortStr,
orElse: () => ContactSortOption.lastSeen,
);
}
_contactsShowUnreadOnly =
prefs.getBool(_keyContactsShowUnreadOnly) ?? false;
final typeStr = prefs.getString(_keyContactsTypeFilter);
if (typeStr != null) {
_contactsTypeFilter = ContactTypeFilter.values.firstWhere(
(e) => e.name == typeStr,
orElse: () => ContactTypeFilter.all,
);
}
final channelSortStr = prefs.getString(_keyChannelsSortOption);
if (channelSortStr != null) {
_channelsSortOption = ChannelSortOption.values.firstWhere(
(e) => e.name == channelSortStr,
orElse: () => ChannelSortOption.manual,
);
return;
}
// Backward compatibility for old persisted index format.
switch (prefs.getInt(_keyChannelsSortIndexLegacy) ?? 0) {
case 0:
_channelsSortOption = ChannelSortOption.manual;
break;
case 1:
_channelsSortOption = ChannelSortOption.name;
break;
case 2:
_channelsSortOption = ChannelSortOption.latestMessages;
break;
case 3:
_channelsSortOption = ChannelSortOption.unread;
break;
default:
_channelsSortOption = ChannelSortOption.manual;
}
}
void setContactsSelectedGroupName(String value) {
if (_contactsSelectedGroupName == value) return;
_contactsSelectedGroupName = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSelectedGroupName, value),
);
}
void setContactsSearchText(String value) {
if (_contactsSearchText == value) return;
_contactsSearchText = value;
notifyListeners();
}
void setContactsSearchExpanded(bool value) {
if (_contactsSearchExpanded == value) return;
_contactsSearchExpanded = value;
notifyListeners();
}
void setContactsSortOption(ContactSortOption value) {
if (_contactsSortOption == value) return;
_contactsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsSortOption, value.name),
);
}
void setContactsShowUnreadOnly(bool value) {
if (_contactsShowUnreadOnly == value) return;
_contactsShowUnreadOnly = value;
notifyListeners();
unawaited(PrefsManager.instance.setBool(_keyContactsShowUnreadOnly, value));
}
void setContactsTypeFilter(ContactTypeFilter value) {
if (_contactsTypeFilter == value) return;
_contactsTypeFilter = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyContactsTypeFilter, value.name),
);
}
void setChannelsSearchText(String value) {
if (_channelsSearchText == value) return;
_channelsSearchText = value;
notifyListeners();
}
void setChannelsSortOption(ChannelSortOption value) {
if (_channelsSortOption == value) return;
_channelsSortOption = value;
notifyListeners();
unawaited(
PrefsManager.instance.setString(_keyChannelsSortOption, value.name),
);
}
}
@@ -189,6 +189,10 @@ class UsbSerialService {
serial.setStopBits1(); serial.setStopBits1();
serial.setFlowControlNone(); serial.setFlowControlNone();
serial.setRTS(false); serial.setRTS(false);
// Toggle DTR lowhigh so the device sees a fresh connection even
// if the previous disconnect didn't cleanly signal DTR drop.
serial.setDTR(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
serial.setDTR(true); serial.setDTR(true);
_serial = serial; _serial = serial;
// Update the normalized port name to whichever candidate succeeded. // Update the normalized port name to whichever candidate succeeded.
@@ -249,6 +253,21 @@ class UsbSerialService {
_status = UsbSerialStatus.connected; _status = UsbSerialStatus.connected;
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected) {
throw StateError('USB serial port is not open');
}
if (_useAndroidUsbHost) {
try {
await _androidMethodChannel.invokeMethod<void>('write', {'data': data});
} on PlatformException catch (error) {
throw StateError(error.message ?? error.code);
}
} else {
_serial!.write(data);
}
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected) { if (!isConnected) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
@@ -300,6 +319,7 @@ class UsbSerialService {
_serial = null; _serial = null;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); serial?.closePort();
} }
} catch (_) { } catch (_) {
@@ -350,6 +370,7 @@ class UsbSerialService {
final serial = _serial; final serial = _serial;
try { try {
if (serial?.isOpen() == FlOpenStatus.open) { if (serial?.isOpen() == FlOpenStatus.open) {
serial?.setDTR(false);
serial?.closePort(); // synchronous C call kills the SerialThread serial?.closePort(); // synchronous C call kills the SerialThread
} }
} catch (_) {} } catch (_) {}
+11
View File
@@ -127,6 +127,17 @@ class UsbSerialService {
} }
} }
Future<void> writeRaw(Uint8List data) async {
if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open');
}
final promise = _writer!.callMethod<JSPromise<JSAny?>>(
'write'.toJS,
data.toJS,
);
await promise.toDart;
}
Future<void> write(Uint8List data) async { Future<void> write(Uint8List data) async {
if (!isConnected || _writer == null) { if (!isConnected || _writer == null) {
throw StateError('USB serial port is not open'); throw StateError('USB serial port is not open');
+8 -7
View File
@@ -23,23 +23,23 @@ class AppLogger {
bool get isEnabled => _enabled; bool get isEnabled => _enabled;
/// Log an info message /// Log an info message
void info(String message, {String tag = 'App'}) { void info(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.info(message, tag: tag); _service!.info(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log a warning message /// Log a warning message
void warn(String message, {String tag = 'App'}) { void warn(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.warn(message, tag: tag); _service!.warn(message, tag: tag, noNotify: noNotify);
} }
} }
/// Log an error message /// Log an error message
void error(String message, {String tag = 'App'}) { void error(String message, {String tag = 'App', bool noNotify = false}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.error(message, tag: tag); _service!.error(message, tag: tag, noNotify: noNotify);
} }
} }
@@ -48,9 +48,10 @@ class AppLogger {
String message, { String message, {
String tag = 'App', String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info, AppDebugLogLevel level = AppDebugLogLevel.info,
bool noNotify = false,
}) { }) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level); _service!.log(message, tag: tag, level: level, noNotify: noNotify);
} }
} }
} }
-3
View File
@@ -1,3 +0,0 @@
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
-2
View File
@@ -1,7 +1,5 @@
import '../models/contact.dart'; import '../models/contact.dart';
export 'contact_filter_types.dart';
bool matchesContactQuery(Contact contact, String query) { bool matchesContactQuery(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase(); final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) return true; if (normalizedQuery.isEmpty) return true;
+99 -70
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../utils/contact_search.dart';
class SortFilterMenuOption<T> { enum ContactSortOption { lastSeen, recentMessages, name }
final T value;
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
final String label; final String label;
final bool? checked; final bool? checked;
@@ -14,16 +17,16 @@ class SortFilterMenuOption<T> {
}); });
} }
class SortFilterMenuSection<T> { class SortFilterMenuSection {
final String title; final String title;
final List<SortFilterMenuOption<T>> options; final List<SortFilterMenuOption> options;
const SortFilterMenuSection({required this.title, required this.options}); const SortFilterMenuSection({required this.title, required this.options});
} }
class SortFilterMenu<T> extends StatelessWidget { class SortFilterMenu extends StatelessWidget {
final List<SortFilterMenuSection<T>> sections; final List<SortFilterMenuSection> sections;
final ValueChanged<T> onSelected; final ValueChanged<int> onSelected;
final String tooltip; final String tooltip;
final Widget icon; final Widget icon;
@@ -37,7 +40,7 @@ class SortFilterMenu<T> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<T>( return PopupMenuButton<int>(
icon: icon, icon: icon,
tooltip: tooltip, tooltip: tooltip,
onSelected: onSelected, onSelected: onSelected,
@@ -50,11 +53,11 @@ class SortFilterMenu<T> extends StatelessWidget {
final visibleSections = sections final visibleSections = sections
.where((section) => section.options.isNotEmpty) .where((section) => section.options.isNotEmpty)
.toList(); .toList();
final entries = <PopupMenuEntry<T>>[]; final entries = <PopupMenuEntry<int>>[];
for (int i = 0; i < visibleSections.length; i++) { for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i]; final section = visibleSections[i];
entries.add( entries.add(
PopupMenuItem<T>( PopupMenuItem<int>(
enabled: false, enabled: false,
child: Text(section.title, style: labelStyle), child: Text(section.title, style: labelStyle),
), ),
@@ -62,14 +65,14 @@ class SortFilterMenu<T> extends StatelessWidget {
for (final option in section.options) { for (final option in section.options) {
if (option.checked == null) { if (option.checked == null) {
entries.add( entries.add(
PopupMenuItem<T>( PopupMenuItem<int>(
value: option.value, value: option.value,
child: Text(option.label), child: Text(option.label),
), ),
); );
} else { } else {
entries.add( entries.add(
CheckedPopupMenuItem<T>( CheckedPopupMenuItem<int>(
value: option.value, value: option.value,
checked: option.checked ?? false, checked: option.checked ?? false,
child: Text(option.label), child: Text(option.label),
@@ -87,23 +90,16 @@ class SortFilterMenu<T> extends StatelessWidget {
} }
} }
sealed class _ContactsFilterAction { const int _actionSortRecentMessages = 1;
const _ContactsFilterAction(); const int _actionSortName = 2;
} const int _actionSortLastSeen = 3;
const int _actionFilterAll = 4;
class _SortAction extends _ContactsFilterAction { const int _actionFilterFavorites = 5;
final ContactSortOption option; const int _actionFilterUsers = 6;
const _SortAction(this.option); const int _actionFilterRepeaters = 7;
} const int _actionFilterRooms = 8;
const int _actionToggleUnreadOnly = 9;
class _TypeFilterAction extends _ContactsFilterAction { const int _actionNewGroup = 10;
final ContactTypeFilter filter;
const _TypeFilterAction(this.filter);
}
class _ToggleUnreadAction extends _ContactsFilterAction {
const _ToggleUnreadAction();
}
class ContactsFilterMenu extends StatelessWidget { class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption; final ContactSortOption sortOption;
@@ -112,6 +108,7 @@ class ContactsFilterMenu extends StatelessWidget {
final ValueChanged<ContactSortOption> onSortChanged; final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged; final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged; final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onNewGroup;
const ContactsFilterMenu({ const ContactsFilterMenu({
super.key, super.key,
@@ -121,29 +118,30 @@ class ContactsFilterMenu extends StatelessWidget {
required this.onSortChanged, required this.onSortChanged,
required this.onTypeFilterChanged, required this.onTypeFilterChanged,
required this.onUnreadOnlyChanged, required this.onUnreadOnlyChanged,
required this.onNewGroup,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return SortFilterMenu<_ContactsFilterAction>( return SortFilterMenu(
tooltip: l10n.listFilter_tooltip, tooltip: l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection(
title: l10n.listFilter_sortBy, title: l10n.listFilter_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _SortAction(ContactSortOption.recentMessages), value: _actionSortRecentMessages,
label: l10n.listFilter_latestMessages, label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages, checked: sortOption == ContactSortOption.recentMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _SortAction(ContactSortOption.lastSeen), value: _actionSortLastSeen,
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _SortAction(ContactSortOption.name), value: _actionSortName,
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -153,66 +151,80 @@ class ContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _TypeFilterAction(ContactTypeFilter.all), value: _actionFilterAll,
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _TypeFilterAction(ContactTypeFilter.favorites), value: _actionFilterFavorites,
label: l10n.listFilter_favorites, label: l10n.listFilter_favorites,
checked: typeFilter == ContactTypeFilter.favorites, checked: typeFilter == ContactTypeFilter.favorites,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _TypeFilterAction(ContactTypeFilter.users), value: _actionFilterUsers,
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _TypeFilterAction(ContactTypeFilter.repeaters), value: _actionFilterRepeaters,
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _TypeFilterAction(ContactTypeFilter.rooms), value: _actionFilterRooms,
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: const _ToggleUnreadAction(), value: _actionToggleUnreadOnly,
label: l10n.listFilter_unreadOnly, label: l10n.listFilter_unreadOnly,
checked: showUnreadOnly, checked: showUnreadOnly,
), ),
SortFilterMenuOption(
value: _actionNewGroup,
label: l10n.listFilter_newGroup,
),
], ],
), ),
], ],
onSelected: (action) { onSelected: (action) {
switch (action) { switch (action) {
case _SortAction(:final option): case _actionSortRecentMessages:
onSortChanged(option); onSortChanged(ContactSortOption.recentMessages);
case _TypeFilterAction(:final filter): break;
onTypeFilterChanged(filter); case _actionSortName:
case _ToggleUnreadAction(): onSortChanged(ContactSortOption.name);
break;
case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
case _actionToggleUnreadOnly:
onUnreadOnlyChanged(!showUnreadOnly); onUnreadOnlyChanged(!showUnreadOnly);
break;
case _actionNewGroup:
onNewGroup();
break;
} }
}, },
); );
} }
} }
sealed class _DiscoveryFilterAction {
const _DiscoveryFilterAction();
}
class _DiscoverySortAction extends _DiscoveryFilterAction {
final ContactSortOption option;
const _DiscoverySortAction(this.option);
}
class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction {
final ContactTypeFilter filter;
const _DiscoveryTypeFilterAction(this.filter);
}
class DiscoveryContactsFilterMenu extends StatelessWidget { class DiscoveryContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption; final ContactSortOption sortOption;
final ContactTypeFilter typeFilter; final ContactTypeFilter typeFilter;
@@ -230,19 +242,19 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return SortFilterMenu<_DiscoveryFilterAction>( return SortFilterMenu(
tooltip: l10n.listFilter_tooltip, tooltip: l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection(
title: l10n.listFilter_sortBy, title: l10n.listFilter_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoverySortAction(ContactSortOption.lastSeen), value: _actionSortLastSeen,
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoverySortAction(ContactSortOption.name), value: _actionSortName,
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -252,22 +264,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.all), value: _actionFilterAll,
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.users), value: _actionFilterUsers,
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters), value: _actionFilterRepeaters,
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms), value: _actionFilterRooms,
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
@@ -276,10 +288,27 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
], ],
onSelected: (action) { onSelected: (action) {
switch (action) { switch (action) {
case _DiscoverySortAction(:final option): case _actionSortName:
onSortChanged(option); onSortChanged(ContactSortOption.name);
case _DiscoveryTypeFilterAction(:final filter): break;
onTypeFilterChanged(filter); case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
} }
}, },
); );
+2 -2
View File
@@ -78,7 +78,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen( builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace, title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes), path: Uint8List.fromList(pathBytes),
flipPathRound: true, flipPathAround: true,
targetContact: widget.contact, targetContact: widget.contact,
), ),
), ),
@@ -107,7 +107,7 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
} }
final pathForInput = currentContact.pathIdList; final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex) .where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList(); .toList();
+2 -1
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -65,7 +66,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() { void _filterValidContacts() {
_validContacts = widget.availableContacts _validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3) .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList(); .toList();
} }
+1 -4
View File
@@ -157,10 +157,7 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr, repeater.snr,
widget.connector.currentSf, widget.connector.currentSf,
); );
final allContacts = [ final allContacts = widget.connector.allContacts;
...widget.connector.contacts,
...widget.connector.discoveredContacts,
];
final name = allContacts final name = allContacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte) .where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name) .map((c) => c.name)
@@ -9,7 +9,6 @@ import flutter_blue_plus_darwin
import flutter_local_notifications import flutter_local_notifications
import mobile_scanner import mobile_scanner
import package_info_plus import package_info_plus
import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
@@ -21,7 +20,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))