Compare commits

...

21 Commits

Author SHA1 Message Date
zjs81 64d75dde45 chore: update version to 7.0.0+8 in pubspec.yaml 2026-03-14 18:46:29 -07:00
zjs81 9199aab7f7 Merge pull request #297 from zjs81/dev-improments
Improvements to path tracing and location handling
2026-03-14 18:42:58 -07:00
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
zjs81 64698e0be6 Merge pull request #295 from ericszimmermann/ez_group_dropdown3
squashed PR for Dropdown Group Menu
2026-03-14 18:05:22 -07:00
zjs81 3dd9037be3 Merge remote-tracking branch 'origin/main' into ez_group_dropdown3
# Conflicts:
#	lib/main.dart
2026-03-14 18:02:31 -07:00
zjs81 566e3aadf8 fix: migrate filter menus to type-safe generics and harden popup dismissal
- Move ContactSortOption/ContactTypeFilter enums to dedicated
  contact_filter_types.dart (re-exported from contact_search.dart)
- Migrate ContactsFilterMenu and DiscoveryContactsFilterMenu to use
  sealed class action types with SortFilterMenu<T> generics, replacing
  int action constants and switch statements
- Guard _closeDropdownAndRun with ModalRoute.isCurrent check to prevent
  accidental dismissal of parent routes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:59:48 -07:00
Winston Lowe 06a906f4f7 Enhance location handling and improve path trace functionality across screens 2026-03-14 17:51:24 -07:00
zjs81 054a84031e Merge pull request #296 from zjs81/feature/ml-timeout-prediction
feat: add ML-based adaptive timeout prediction using LinearRegressor
2026-03-14 17:39:22 -07:00
zjs81 fffcff3b74 fix: cancel persist timer on dispose to prevent post-dispose writes 2026-03-14 17:39:01 -07:00
zjs81 b336aedbc5 fix: address PR #296 code review feedback
- Clamp ML predictions between physics floor (raw airtime) and ceiling
  (worst-case formula) so model can never produce unsafe timeouts
- Replace hourOfDay feature with secondsSinceLastRx for network activity
- Remove unused _ContactStats.stdDev and dead model persistence code
- Debounce observation writes (2s) instead of writing on every delivery
- Skip recording observations when pathLength is null to avoid corrupting
  training data
- Add comment explaining global (not per-contact) RX time tracking
- Remove notifyListeners from retrain to avoid unnecessary widget rebuilds
- Run dart format
2026-03-14 17:32:08 -07:00
zjs81 2ee2358ecc feat: add ML-based adaptive timeout prediction using LinearRegressor
Train a linear regression model on actual message delivery times to
predict tighter timeouts, replacing worst-case physics estimates.
Features: path length, message bytes, seconds since last RX, flood mode.
Global model with per-contact blending after 10+ observations per contact.
Falls back to existing physics formula when model has insufficient data.
2026-03-14 16:56:11 -07:00
ericz 86e9b7fe01 squashed commit of ez_group_dropdown 2026-03-15 00:34:09 +01: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
64 changed files with 1802 additions and 612 deletions
+115 -30
View File
@@ -19,6 +19,7 @@ import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
import '../services/app_settings_service.dart'; import '../services/app_settings_service.dart';
import '../services/background_service.dart'; import '../services/background_service.dart';
import '../services/timeout_prediction_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'meshcore_connector_usb.dart'; import 'meshcore_connector_usb.dart';
import 'meshcore_connector_tcp.dart'; import 'meshcore_connector_tcp.dart';
@@ -166,6 +167,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool _isLoadingContacts = false; bool _isLoadingContacts = false;
bool _isLoadingChannels = false; bool _isLoadingChannels = false;
bool _hasLoadedChannels = false; bool _hasLoadedChannels = false;
TimeoutPredictionService? _timeoutPredictionService;
// Intentionally global (not per-contact): tracks overall network activity.
// Frequent RX from any source indicates a busy network with more collisions.
DateTime _lastRxTime = DateTime.now();
bool _batteryRequested = false; bool _batteryRequested = false;
bool _awaitingSelfInfo = false; bool _awaitingSelfInfo = false;
bool _hasReceivedDeviceInfo = false; bool _hasReceivedDeviceInfo = false;
@@ -289,6 +294,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);
} }
@@ -668,6 +677,7 @@ class MeshCoreConnector extends ChangeNotifier {
BleDebugLogService? bleDebugLogService, BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService, AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
TimeoutPredictionService? timeoutPredictionService,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
@@ -675,6 +685,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService; _appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
_timeoutPredictionService = timeoutPredictionService;
_usbManager.setDebugLogService(_appDebugLogService); _usbManager.setDebugLogService(_appDebugLogService);
_tcpConnector.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService);
@@ -689,13 +700,28 @@ class MeshCoreConnector extends ChangeNotifier {
updateMessageCallback: _updateMessage, updateMessageCallback: _updateMessage,
clearContactPathCallback: clearContactPath, clearContactPathCallback: clearContactPath,
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback:
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), (pathLength, messageBytes, {String? contactKey}) => calculateTimeout(
pathLength: pathLength,
messageBytes: messageBytes,
contactKey: contactKey,
),
getSelfPublicKeyCallback: () => _selfPublicKey, getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText, prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService, debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, recordPathResultCallback: _recordPathResult,
onDeliveryObservedCallback:
(contactKey, pathLength, messageBytes, tripTimeMs) {
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
_timeoutPredictionService?.recordObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
tripTimeMs: tripTimeMs,
secondsSinceLastRx: secSinceRx,
);
},
); );
} }
@@ -704,6 +730,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);
} }
@@ -1536,6 +1565,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 {
@@ -2498,6 +2531,7 @@ class MeshCoreConnector extends ChangeNotifier {
void _handleFrame(List<int> data) { void _handleFrame(List<int> data) {
if (data.isEmpty) return; if (data.isEmpty) return;
_lastRxTime = DateTime.now();
final frame = Uint8List.fromList(data); final frame = Uint8List.fromList(data);
_receivedFramesController.add(frame); _receivedFramesController.add(frame);
@@ -2874,41 +2908,73 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
/// Calculate timeout for a message based on radio settings and path length /// Estimate single-packet airtime in ms from radio settings, or a fallback.
/// Returns timeout in milliseconds, considering number of hops int _estimateAirtimeMs(int messageBytes) {
int calculateTimeout({required int pathLength, int messageBytes = 100}) {
// If we have radio settings, use them for accurate calculation
if (_currentFreqHz != null && if (_currentFreqHz != null &&
_currentBwHz != null && _currentBwHz != null &&
_currentSf != null && _currentSf != null &&
_currentCr != null) { _currentCr != null) {
final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4; final cr = _currentCr! <= 4 ? _currentCr! : _currentCr! - 4;
return calculateMessageTimeout( return calculateLoRaAirtime(
freqHz: _currentFreqHz!, payloadBytes: messageBytes,
bwHz: _currentBwHz!, spreadingFactor: _currentSf!,
sf: _currentSf!, bandwidthHz: _currentBwHz!,
cr: cr, codingRate: cr,
pathLength: pathLength, lowDataRateOptimize: _currentSf! >= 11,
messageBytes: messageBytes,
); );
} }
return 50; // fallback: ~SF7/BW125 for 100 bytes
}
// Fallback: Conservative estimates based on typical settings /// Physics-based worst-case timeout (ceiling).
// Assume SF7, BW125, which gives ~50ms airtime for 100 bytes int _physicsMaxTimeout(int pathLength, int airtime) {
const estimatedAirtime = 50;
if (pathLength < 0) { if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime return 500 + (16 * airtime);
return 500 + (16 * estimatedAirtime);
} else { } else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1)) return 500 + ((airtime * 6 + 250) * (pathLength + 1));
return 500 + ((estimatedAirtime * 6 + 250) * (pathLength + 1));
} }
} }
/// Physics-based minimum timeout (floor): raw traversal time.
int _physicsMinTimeout(int pathLength, int airtime) {
if (pathLength < 0) {
return airtime;
} else {
return airtime * (pathLength + 1);
}
}
/// Calculate timeout for a message based on radio settings and path length.
/// Returns timeout in milliseconds, considering number of hops.
int calculateTimeout({
required int pathLength,
int messageBytes = 100,
String? contactKey,
}) {
final airtime = _estimateAirtimeMs(messageBytes);
final physicsMin = _physicsMinTimeout(pathLength, airtime);
final physicsMax = _physicsMaxTimeout(pathLength, airtime);
// Try ML-based prediction, clamped between physics bounds
final secSinceRx = DateTime.now().difference(_lastRxTime).inSeconds;
final mlTimeout = _timeoutPredictionService?.predictTimeout(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secSinceRx,
);
if (mlTimeout != null) {
return mlTimeout.clamp(physicsMin, physicsMax);
}
return physicsMax;
}
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(
@@ -4717,6 +4783,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);
} }
@@ -4741,8 +4813,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,
@@ -4813,11 +4897,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');
} }
@@ -4827,6 +4911,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');
@@ -4847,7 +4932,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());
@@ -4865,7 +4950,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);
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -416,6 +416,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+6
View File
@@ -1714,6 +1714,12 @@ 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:
+3
View File
@@ -902,6 +902,9 @@ 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\" вече съществува.';
+3
View File
@@ -902,6 +902,9 @@ 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.';
+3
View File
@@ -889,6 +889,9 @@ 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';
+4
View File
@@ -901,6 +901,10 @@ 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';
+3
View File
@@ -905,6 +905,9 @@ 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à.';
+3
View File
@@ -901,6 +901,9 @@ 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à.';
+3
View File
@@ -895,6 +895,9 @@ 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.';
+3
View File
@@ -904,6 +904,9 @@ 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';
+3
View File
@@ -903,6 +903,9 @@ 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';
+3
View File
@@ -902,6 +902,9 @@ 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\" уже существует';
+3
View File
@@ -894,6 +894,9 @@ 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';
+3
View File
@@ -892,6 +892,9 @@ 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';
+3
View File
@@ -888,6 +888,9 @@ 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.';
+3
View File
@@ -898,6 +898,9 @@ 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» вже існує.';
+3
View File
@@ -845,6 +845,9 @@ 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\" 的群聊已存在';
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -212,6 +212,7 @@
"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": "Нет контактов, соответствующих фильтру",
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -285,6 +285,7 @@
"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": {
+1
View File
@@ -286,6 +286,7 @@
"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": {
+1
View File
@@ -300,6 +300,7 @@
"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": {
+15
View File
@@ -19,6 +19,8 @@ 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 'services/timeout_prediction_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart'; import 'utils/app_logger.dart';
@@ -39,6 +41,8 @@ void main() async {
final backgroundService = BackgroundService(); final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(); final mapTileCacheService = MapTileCacheService();
final chatTextScaleService = ChatTextScaleService(); final chatTextScaleService = ChatTextScaleService();
final uiViewStateService = UiViewStateService();
final timeoutPredictionService = TimeoutPredictionService(storage);
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
@@ -56,6 +60,8 @@ void main() async {
_registerThirdPartyLicenses(); _registerThirdPartyLicenses();
await chatTextScaleService.initialize(); await chatTextScaleService.initialize();
await uiViewStateService.initialize();
await timeoutPredictionService.initialize();
// Wire up connector with services // Wire up connector with services
connector.initialize( connector.initialize(
@@ -65,6 +71,7 @@ void main() async {
bleDebugLogService: bleDebugLogService, bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
backgroundService: backgroundService, backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
); );
await connector.loadContactCache(); await connector.loadContactCache();
@@ -86,6 +93,8 @@ void main() async {
appDebugLogService: appDebugLogService, appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService, mapTileCacheService: mapTileCacheService,
chatTextScaleService: chatTextScaleService, chatTextScaleService: chatTextScaleService,
uiViewStateService: uiViewStateService,
timeoutPredictionService: timeoutPredictionService,
), ),
); );
} }
@@ -121,6 +130,8 @@ 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;
final TimeoutPredictionService timeoutPredictionService;
const MeshCoreApp({ const MeshCoreApp({
super.key, super.key,
@@ -133,6 +144,8 @@ 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,
required this.timeoutPredictionService,
}); });
@override @override
@@ -146,8 +159,10 @@ 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),
ChangeNotifierProvider.value(value: timeoutPredictionService),
], ],
child: Consumer<AppSettingsService>( child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) { builder: (context, settingsService, child) {
+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;
+43
View File
@@ -0,0 +1,43 @@
class DeliveryObservation {
final String contactKey;
final int pathLength;
final int messageBytes;
final int secondsSinceLastRx;
final bool isFlood;
final int deliveryMs;
final DateTime timestamp;
DeliveryObservation({
required this.contactKey,
required this.pathLength,
required this.messageBytes,
required this.secondsSinceLastRx,
required this.isFlood,
required this.deliveryMs,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'contact_key': contactKey,
'path_length': pathLength,
'message_bytes': messageBytes,
'seconds_since_last_rx': secondsSinceLastRx,
'is_flood': isFlood,
'delivery_ms': deliveryMs,
'timestamp': timestamp.toIso8601String(),
};
}
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
return DeliveryObservation(
contactKey: json['contact_key'] as String,
pathLength: json['path_length'] as int,
messageBytes: json['message_bytes'] as int,
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
isFlood: json['is_flood'] as bool,
deliveryMs: json['delivery_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
+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>[];
+44 -55
View File
@@ -11,6 +11,7 @@ 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';
@@ -28,8 +29,6 @@ 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;
@@ -43,9 +42,7 @@ 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
@@ -56,6 +53,9 @@ 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,6 +110,7 @@ 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;
@@ -205,6 +206,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
final filteredChannels = _filterAndSortChannels( final filteredChannels = _filterAndSortChannels(
channels, channels,
connector, connector,
viewState,
); );
return Column( return Column(
@@ -219,17 +221,19 @@ class _ChannelsScreenState extends State<ChannelsScreen>
suffixIcon: Row( suffixIcon: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_searchQuery.isNotEmpty) if (viewState.channelsSearchText.isNotEmpty)
IconButton( IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchDebounce?.cancel();
_searchDebounce = null;
_searchController.clear(); _searchController.clear();
setState(() { context
_searchQuery = ''; .read<UiViewStateService>()
}); .setChannelsSearchText('');
}, },
), ),
_buildFilterButton(), _buildFilterButton(viewState),
], ],
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -246,9 +250,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
() { () {
if (!mounted) return; if (!mounted) return;
setState(() { context
_searchQuery = value.toLowerCase(); .read<UiViewStateService>()
}); .setChannelsSearchText(value);
}, },
); );
}, },
@@ -283,8 +287,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
], ],
) )
: (_sortOption == ChannelSortOption.manual && : (viewState.channelsSortOption ==
_searchQuery.isEmpty) ChannelSortOption.manual &&
viewState.channelsSearchText.isEmpty)
? ReorderableListView.builder( ? ReorderableListView.builder(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 16,
@@ -584,59 +589,40 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton() { Widget _buildFilterButton(UiViewStateService viewState) {
const actionSortManual = 0; return SortFilterMenu<ChannelSortOption>(
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
tooltip: context.l10n.listFilter_tooltip, tooltip: context.l10n.listFilter_tooltip,
sections: [ sections: [
SortFilterMenuSection( SortFilterMenuSection<ChannelSortOption>(
title: context.l10n.channels_sortBy, title: context.l10n.channels_sortBy,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortManual, value: ChannelSortOption.manual,
label: context.l10n.channels_sortManual, label: context.l10n.channels_sortManual,
checked: _sortOption == ChannelSortOption.manual, checked: viewState.channelsSortOption == ChannelSortOption.manual,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortName, value: ChannelSortOption.name,
label: context.l10n.channels_sortAZ, label: context.l10n.channels_sortAZ,
checked: _sortOption == ChannelSortOption.name, checked: viewState.channelsSortOption == ChannelSortOption.name,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortLatest, value: ChannelSortOption.latestMessages,
label: context.l10n.channels_sortLatestMessages, label: context.l10n.channels_sortLatestMessages,
checked: _sortOption == ChannelSortOption.latestMessages, checked:
viewState.channelsSortOption ==
ChannelSortOption.latestMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption<ChannelSortOption>(
value: actionSortUnread, value: ChannelSortOption.unread,
label: context.l10n.channels_sortUnread, label: context.l10n.channels_sortUnread,
checked: _sortOption == ChannelSortOption.unread, checked: viewState.channelsSortOption == ChannelSortOption.unread,
), ),
], ],
), ),
], ],
onSelected: (action) { onSelected: (sortOption) {
setState(() { viewState.setChannelsSortOption(sortOption);
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;
}
});
}, },
); );
} }
@@ -644,11 +630,14 @@ 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 (_searchQuery.isEmpty) return true; if (viewState.channelsSearchText.isEmpty) return true;
final label = _normalizeChannelName(channel); final label = _normalizeChannelName(channel);
return label.toLowerCase().contains(_searchQuery); return label.toLowerCase().contains(
viewState.channelsSearchText.toLowerCase(),
);
}).toList(); }).toList();
int compareByName(Channel a, Channel b) { int compareByName(Channel a, Channel b) {
@@ -657,7 +646,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return nameA.toLowerCase().compareTo(nameB.toLowerCase()); return nameA.toLowerCase().compareTo(nameB.toLowerCase());
} }
switch (_sortOption) { switch (viewState.channelsSortOption) {
case ChannelSortOption.manual: case ChannelSortOption.manual:
break; break;
case ChannelSortOption.latestMessages: case ChannelSortOption.latestMessages:
+2 -2
View File
@@ -858,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,
), ),
), ),
@@ -1027,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();
+465 -285
View File
@@ -13,8 +13,9 @@ 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 '../storage/contact_group_store.dart'; import '../services/ui_view_state_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../storage/contact_group_store.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,12 +49,10 @@ 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 = {};
@@ -63,6 +62,9 @@ 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(); _clearAdvertNotifications();
@@ -74,26 +76,84 @@ class _ContactsScreenState extends State<ContactsScreen>
NotificationService().clearAdvertNotifications(contactIds); NotificationService().clearAdvertNotifications(contactIds);
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
final connector = context.read<MeshCoreConnector>();
if (!identical(_scopeSyncConnector, connector)) {
_scopeSyncConnector?.removeListener(_handleConnectorScopeChange);
_scopeSyncConnector = connector;
_scopeSyncConnector?.addListener(_handleConnectorScopeChange);
}
_handleConnectorScopeChange();
}
@override @override
void dispose() { void dispose() {
_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
@@ -383,31 +443,166 @@ class _ContactsScreenState extends State<ContactsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) { ContactGroup? _selectedGroupForName(String selectedGroupName) {
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: _sortOption, sortOption: viewState.contactsSortOption,
typeFilter: _typeFilter, typeFilter: viewState.contactsTypeFilter,
showUnreadOnly: _showUnreadOnly, showUnreadOnly: viewState.contactsShowUnreadOnly,
onSortChanged: (value) { onSortChanged: (value) {
setState(() { viewState.setContactsSortOption(value);
_sortOption = value;
});
}, },
onTypeFilterChanged: (value) { onTypeFilterChanged: (value) {
setState(() { viewState.setContactsTypeFilter(value);
_typeFilter = value;
});
}, },
onUnreadOnlyChanged: (value) { onUnreadOnlyChanged: (value) {
setState(() { viewState.setContactsShowUnreadOnly(value);
_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 &&
@@ -429,92 +624,171 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
final filteredAndSorted = _filterAndSortContacts(contacts, connector); final filteredAndSorted = _filterAndSortContacts(
final filteredGroups = _showUnreadOnly contacts,
? const <ContactGroup>[] connector,
: _filterAndSortGroups(_groups, contacts); viewState,
);
String hintText = ""; String hintText = "";
switch (_typeFilter) { switch (viewState.contactsTypeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
hintText = context.l10n.contacts_searchContacts( hintText = context.l10n.contacts_searchContacts(
filteredAndSorted.length, filteredAndSorted.length,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${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,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${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,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${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,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${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,
_showUnreadOnly ? " ${context.l10n.contacts_unread}" : "", viewState.contactsShowUnreadOnly
? " ${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: TextField( child: Row(
controller: _searchController, children: [
decoration: InputDecoration( Expanded(
hintText: hintText, child: _buildGroupButton(
prefixIcon: const Icon(Icons.search), context,
suffixIcon: Row( connector,
mainAxisSize: MainAxisSize.min, viewState,
children: [ contacts,
if (_searchQuery.isNotEmpty) sortedGroups,
IconButton( ),
icon: const Icon(Icons.clear), ),
onPressed: () { const SizedBox(width: 8),
_searchController.clear(); AnimatedContainer(
setState(() { duration: const Duration(milliseconds: 220),
_searchQuery = ''; curve: Curves.easeOutCubic,
}); width: viewState.contactsSearchExpanded
}, ? searchExpandedWidth
: searchCollapsedWidth,
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
), ),
_buildFilterButton(context, connector), borderRadius: BorderRadius.circular(12),
], ),
child: Row(
children: [
Expanded(
child: viewState.contactsSearchExpanded
? TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() {
if (!mounted) return;
context
.read<UiViewStateService>()
.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),
),
],
),
),
), ),
border: OutlineInputBorder( ],
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_searchQuery = value.toLowerCase();
});
});
},
), ),
), ),
Expanded( Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty child: filteredAndSorted.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -522,7 +796,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(
_showUnreadOnly viewState.contactsShowUnreadOnly
? 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]),
@@ -533,14 +807,9 @@ class _ContactsScreenState extends State<ContactsScreen>
: RefreshIndicator( : RefreshIndicator(
onRefresh: () => connector.getContacts(), onRefresh: () => connector.getContacts(),
child: ListView.builder( child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length, itemCount: filteredAndSorted.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index < filteredGroups.length) { final contact = filteredAndSorted[index];
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,
); );
@@ -561,55 +830,26 @@ 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 (_searchQuery.isEmpty) return true; if (viewState.contactsSearchText.isEmpty) return true;
return matchesContactQuery(contact, _searchQuery); return matchesContactQuery(contact, viewState.contactsSearchText);
}).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!);
@@ -618,17 +858,22 @@ class _ContactsScreenState extends State<ContactsScreen>
}).toList(); }).toList();
} }
if (_typeFilter != ContactTypeFilter.all) { if (viewState.contactsTypeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList(); filtered = filtered
.where(
(contact) =>
_matchesTypeFilter(contact, viewState.contactsTypeFilter),
)
.toList();
} }
if (_showUnreadOnly) { if (viewState.contactsShowUnreadOnly) {
filtered = filtered.where((contact) { filtered = filtered.where((contact) {
return connector.getUnreadCountForContact(contact) > 0; return connector.getUnreadCountForContact(contact) > 0;
}).toList(); }).toList();
} }
switch (_sortOption) { switch (viewState.contactsSortOption) {
case ContactSortOption.lastSeen: case ContactSortOption.lastSeen:
filtered.sort( filtered.sort(
(a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)), (a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)),
@@ -657,8 +902,8 @@ class _ContactsScreenState extends State<ContactsScreen>
return filtered; return filtered;
} }
bool _matchesTypeFilter(Contact contact) { bool _matchesTypeFilter(Contact contact, ContactTypeFilter typeFilter) {
switch (_typeFilter) { switch (typeFilter) {
case ContactTypeFilter.all: case ContactTypeFilter.all:
return true; return true;
case ContactTypeFilter.favorites: case ContactTypeFilter.favorites:
@@ -679,57 +924,6 @@ 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) {
@@ -807,58 +1001,11 @@ class _ContactsScreenState extends State<ContactsScreen>
); );
} }
void _showGroupOptions(
BuildContext context,
ContactGroup group,
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) { void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
if (!_hasGroupStoreScope(context.read<MeshCoreConnector>())) {
_showGroupsUnavailableMessage(context);
return;
}
showDialog( showDialog(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AlertDialog(
@@ -874,6 +1021,7 @@ 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();
}, },
@@ -892,6 +1040,10 @@ 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 ?? []};
@@ -918,64 +1070,70 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
content: SizedBox( content: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
children: [ maxHeight: MediaQuery.of(context).size.height * 0.8,
TextField( ),
controller: nameController, child: Column(
decoration: InputDecoration( mainAxisSize: MainAxisSize.min,
labelText: context.l10n.contacts_groupName, children: [
border: const OutlineInputBorder(), TextField(
controller: nameController,
decoration: InputDecoration(
labelText: context.l10n.contacts_groupName,
border: const OutlineInputBorder(),
),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), TextField(
TextField( decoration: InputDecoration(
decoration: InputDecoration( hintText: context.l10n.contacts_filterContacts,
hintText: context.l10n.contacts_filterContacts, prefixIcon: const Icon(Icons.search),
prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(),
border: const OutlineInputBorder(), isDense: true,
isDense: true, ),
onChanged: (value) {
setDialogState(() {
filterQuery = value.toLowerCase();
});
},
), ),
onChanged: (value) { const SizedBox(height: 12),
setDialogState(() { Expanded(
filterQuery = value.toLowerCase(); child: filteredContacts.isEmpty
}); ? Center(
}, child: Text(
), context.l10n.contacts_noContactsMatchFilter,
const SizedBox(height: 12), ),
SizedBox( )
height: 240, : ListView.builder(
child: filteredContacts.isEmpty itemCount: filteredContacts.length,
? Center( itemBuilder: (context, index) {
child: Text( final contact = filteredContacts[index];
context.l10n.contacts_noContactsMatchFilter, final isSelected = selectedKeys.contains(
contact.publicKeyHex,
);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(
contact.publicKeyHex,
);
}
});
},
);
},
), ),
) ),
: ListView.builder( ],
itemCount: filteredContacts.length, ),
itemBuilder: (context, index) {
final contact = filteredContacts[index];
final isSelected = selectedKeys.contains(
contact.publicKeyHex,
);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(contact.publicKeyHex);
}
});
},
);
},
),
),
],
), ),
), ),
actions: [ actions: [
@@ -994,6 +1152,15 @@ 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();
@@ -1009,15 +1176,21 @@ 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(
@@ -1026,7 +1199,9 @@ 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) {
@@ -1064,7 +1239,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: () {
@@ -1072,10 +1247,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,
), ),
), ),
); );
@@ -1100,10 +1277,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,
), ),
), ),
); );
@@ -1145,7 +1324,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,
), ),
), ),
+3 -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
+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()),
+10 -7
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);
} }
notifyListeners(); if (!noNotify) {
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();
PrefsManager.instance.setDouble(_prefKey, _scale); unawaited(PrefsManager.instance.setDouble(_prefKey, _scale));
} }
double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble(); double _clamp(double value) => value.clamp(_minScale, _maxScale).toDouble();
+45 -17
View File
@@ -58,12 +58,13 @@ class MessageRetryService extends ChangeNotifier {
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int, {String? contactKey})? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback; Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback; String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService; AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
Function(String, int, int, int)? _onDeliveryObservedCallback;
MessageRetryService(); MessageRetryService();
@@ -73,12 +74,20 @@ class MessageRetryService extends ChangeNotifier {
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes, {String? contactKey})?
calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback, Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback, String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService, AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
Function(
String contactKey,
int pathLength,
int messageBytes,
int tripTimeMs,
)?
onDeliveryObservedCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback; _addMessageCallback = addMessageCallback;
@@ -91,6 +100,7 @@ class MessageRetryService extends ChangeNotifier {
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_debugLogService = debugLogService; _debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
_onDeliveryObservedCallback = onDeliveryObservedCallback;
} }
/// Compute expected ACK hash using same algorithm as firmware: /// Compute expected ACK hash using same algorithm as firmware:
@@ -423,25 +433,33 @@ class MessageRetryService extends ChangeNotifier {
); );
} }
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Calculate timeout: prefer ML prediction, then device-provided, then physics fallback
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
int actualTimeout = timeoutMs; int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null) { if (_calculateTimeoutCallback != null) {
int pathLengthValue; final calculated = _calculateTimeoutCallback!(
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(
pathLengthValue, pathLengthValue,
message.text.length, message.text.length,
contactKey: contact.publicKeyHex,
); );
debugPrint( // calculateTimeout tries ML first, falls back to physics.
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue', // Use calculated value if device didn't provide one, or if ML
); // produced a tighter prediction than the device's estimate.
if (timeoutMs <= 0 || calculated < timeoutMs) {
actualTimeout = calculated;
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
} }
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
@@ -738,6 +756,16 @@ class MessageRetryService extends ChangeNotifier {
true, true,
tripTimeMs, tripTimeMs,
); );
if (_onDeliveryObservedCallback != null &&
tripTimeMs > 0 &&
message.pathLength != null) {
_onDeliveryObservedCallback!(
contact.publicKeyHex,
message.pathLength!,
message.text.length,
tripTimeMs,
);
}
_onMessageResolved(matchedMessageId, contact.publicKeyHex); _onMessageResolved(matchedMessageId, contact.publicKeyHex);
} }
+31
View File
@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import '../models/delivery_observation.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../storage/prefs_manager.dart'; import '../storage/prefs_manager.dart';
@@ -6,6 +7,7 @@ class StorageService {
static const String _pathHistoryPrefix = 'path_history_'; static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages'; static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
static const String _deliveryObservationsKey = 'delivery_observations';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, String contactPubKeyHex,
@@ -122,4 +124,33 @@ class StorageService {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
await prefs.remove(_repeaterPasswordsKey); await prefs.remove(_repeaterPasswordsKey);
} }
Future<void> saveDeliveryObservations(
List<DeliveryObservation> observations,
) async {
final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(observations.map((o) => o.toJson()).toList());
await prefs.setString(_deliveryObservationsKey, jsonStr);
}
Future<List<DeliveryObservation>> loadDeliveryObservations() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_deliveryObservationsKey);
if (jsonStr == null) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list
.map((e) => DeliveryObservation.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> clearDeliveryObservations() async {
final prefs = PrefsManager.instance;
await prefs.remove(_deliveryObservationsKey);
}
} }
@@ -0,0 +1,229 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
import '../models/delivery_observation.dart';
import 'storage_service.dart';
class _ContactStats {
int count = 0;
double _sum = 0;
void add(double ms) {
count++;
_sum += ms;
}
double get mean => _sum / count;
}
class TimeoutPredictionService extends ChangeNotifier {
final StorageService? _storage;
static const int minObservations = 10;
static const int maxObservations = 100;
static const int _retrainInterval = 5;
// 1.5x multiplier on raw prediction to account for variance in delivery
// times tight enough to improve on worst-case physics, loose enough
// to avoid premature timeouts from model noise.
static const double _safetyMargin = 1.5;
static const int _minContactObservations = 10;
List<DeliveryObservation> _observations = [];
LinearRegressor? _model;
List<String> _activeFeatures = [];
int _observationsSinceLastTrain = 0;
final Map<String, _ContactStats> _contactStats = {};
Timer? _persistTimer;
TimeoutPredictionService(StorageService storage) : _storage = storage;
TimeoutPredictionService.noStorage() : _storage = null;
int get observationCount => _observations.length;
bool get hasModel => _model != null;
Future<void> initialize() async {
_observations = await _storage?.loadDeliveryObservations() ?? [];
_rebuildContactStats();
if (_observations.length >= minObservations) {
_trainModel();
}
debugPrint(
'TimeoutPrediction: initialized with ${_observations.length} observations, '
'model=${_model != null ? "ready" : "waiting for data"}',
);
}
void recordObservation({
required String contactKey,
required int pathLength,
required int messageBytes,
required int tripTimeMs,
int secondsSinceLastRx = 0,
}) {
final observation = DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: secondsSinceLastRx,
isFlood: pathLength < 0,
deliveryMs: tripTimeMs,
timestamp: DateTime.now(),
);
_observations.add(observation);
if (_observations.length > maxObservations) {
_observations.removeAt(0);
}
_contactStats.putIfAbsent(contactKey, () => _ContactStats());
_contactStats[contactKey]!.add(tripTimeMs.toDouble());
_observationsSinceLastTrain++;
if (_observationsSinceLastTrain >= _retrainInterval &&
_observations.length >= minObservations) {
_trainModel();
}
_persistTimer?.cancel();
_persistTimer = Timer(const Duration(seconds: 2), () {
_storage?.saveDeliveryObservations(_observations);
});
debugPrint(
'TimeoutPrediction: recorded ${tripTimeMs}ms for $pathLength hops '
'(${_observations.length} total)',
);
}
int? predictTimeout({
String? contactKey,
required int pathLength,
required int messageBytes,
int secondsSinceLastRx = 0,
}) {
if (_model == null) return null;
try {
if (_activeFeatures.isEmpty) return null;
final allFeatures = {
'pathLength': pathLength.toDouble(),
'messageBytes': messageBytes.toDouble(),
'secSinceRx': secondsSinceLastRx.toDouble(),
'isFlood': pathLength < 0 ? 1.0 : 0.0,
};
final row = _activeFeatures.map((f) => allFeatures[f]!).toList();
final features = DataFrame(
[row],
headerExists: false,
header: _activeFeatures,
);
final prediction = _model!.predict(features);
final rawValue = prediction.rows.first.first;
var predictedMs = (rawValue is double)
? rawValue
: (rawValue as num).toDouble();
debugPrint(
'TimeoutPrediction: raw prediction=$predictedMs for '
'pathLength=$pathLength, messageBytes=$messageBytes, '
'features=$_activeFeatures',
);
// Sanity check: if prediction is negative or zero, fall back
if (predictedMs <= 0) return null;
// Blend with per-contact mean if enough data
if (contactKey != null) {
final stats = _contactStats[contactKey];
if (stats != null && stats.count >= _minContactObservations) {
predictedMs = 0.5 * predictedMs + 0.5 * stats.mean;
}
}
// Connector clamps this between physics min/max bounds
final timeout = (predictedMs * _safetyMargin).ceil();
debugPrint(
'TimeoutPrediction: ML timeout ${timeout}ms '
'(raw: ${predictedMs.round()}ms, contact: $contactKey)',
);
return timeout;
} catch (e) {
debugPrint('TimeoutPrediction: prediction failed: $e');
return null;
}
}
void _trainModel() {
try {
// Build feature columns, then exclude any with zero variance
// (ml_algo's OLS produces all-zero coefficients for singular matrices)
final allNames = ['pathLength', 'messageBytes', 'secSinceRx', 'isFlood'];
final allExtractors = <double Function(DeliveryObservation)>[
(o) => o.pathLength.toDouble(),
(o) => o.messageBytes.toDouble(),
(o) => o.secondsSinceLastRx.toDouble(),
(o) => o.isFlood ? 1.0 : 0.0,
];
_activeFeatures = [];
for (var i = 0; i < allNames.length; i++) {
final values = _observations.map(allExtractors[i]).toSet();
if (values.length > 1) _activeFeatures.add(allNames[i]);
}
if (_activeFeatures.isEmpty) {
debugPrint(
'TimeoutPrediction: no features with variance, skipping training',
);
return;
}
final header = [..._activeFeatures, 'deliveryMs'];
final rows = _observations.map((o) {
final row = <double>[];
for (var i = 0; i < allNames.length; i++) {
if (_activeFeatures.contains(allNames[i])) {
row.add(allExtractors[i](o));
}
}
row.add(o.deliveryMs.toDouble());
return row;
});
final data = DataFrame([header, ...rows], headerExists: true);
_model = LinearRegressor(data, 'deliveryMs');
_observationsSinceLastTrain = 0;
// Log training summary with sample predictions
final avgMs =
_observations.map((o) => o.deliveryMs).reduce((a, b) => a + b) /
_observations.length;
debugPrint(
'TimeoutPrediction: trained on ${_observations.length} observations '
'(avg: ${avgMs.round()}ms, features: $_activeFeatures)',
);
} catch (e) {
debugPrint('TimeoutPrediction: training failed: $e');
}
}
@override
void dispose() {
_persistTimer?.cancel();
super.dispose();
}
void _rebuildContactStats() {
_contactStats.clear();
for (final obs in _observations) {
_contactStats.putIfAbsent(obs.contactKey, () => _ContactStats());
_contactStats[obs.contactKey]!.add(obs.deliveryMs.toDouble());
}
}
}
+154
View File
@@ -0,0 +1,154 @@
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
@@ -0,0 +1,3 @@
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
+2
View File
@@ -1,5 +1,7 @@
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;
+70 -99
View File
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
import '../utils/contact_search.dart';
enum ContactSortOption { lastSeen, recentMessages, name } class SortFilterMenuOption<T> {
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;
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
}); });
} }
class SortFilterMenuSection { class SortFilterMenuSection<T> {
final String title; final String title;
final List<SortFilterMenuOption> options; final List<SortFilterMenuOption<T>> options;
const SortFilterMenuSection({required this.title, required this.options}); const SortFilterMenuSection({required this.title, required this.options});
} }
class SortFilterMenu extends StatelessWidget { class SortFilterMenu<T> extends StatelessWidget {
final List<SortFilterMenuSection> sections; final List<SortFilterMenuSection<T>> sections;
final ValueChanged<int> onSelected; final ValueChanged<T> onSelected;
final String tooltip; final String tooltip;
final Widget icon; final Widget icon;
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<int>( return PopupMenuButton<T>(
icon: icon, icon: icon,
tooltip: tooltip, tooltip: tooltip,
onSelected: onSelected, onSelected: onSelected,
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
final visibleSections = sections final visibleSections = sections
.where((section) => section.options.isNotEmpty) .where((section) => section.options.isNotEmpty)
.toList(); .toList();
final entries = <PopupMenuEntry<int>>[]; final entries = <PopupMenuEntry<T>>[];
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<int>( PopupMenuItem<T>(
enabled: false, enabled: false,
child: Text(section.title, style: labelStyle), child: Text(section.title, style: labelStyle),
), ),
@@ -65,14 +62,14 @@ class SortFilterMenu 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<int>( PopupMenuItem<T>(
value: option.value, value: option.value,
child: Text(option.label), child: Text(option.label),
), ),
); );
} else { } else {
entries.add( entries.add(
CheckedPopupMenuItem<int>( CheckedPopupMenuItem<T>(
value: option.value, value: option.value,
checked: option.checked ?? false, checked: option.checked ?? false,
child: Text(option.label), child: Text(option.label),
@@ -90,16 +87,23 @@ class SortFilterMenu extends StatelessWidget {
} }
} }
const int _actionSortRecentMessages = 1; sealed class _ContactsFilterAction {
const int _actionSortName = 2; const _ContactsFilterAction();
const int _actionSortLastSeen = 3; }
const int _actionFilterAll = 4;
const int _actionFilterFavorites = 5; class _SortAction extends _ContactsFilterAction {
const int _actionFilterUsers = 6; final ContactSortOption option;
const int _actionFilterRepeaters = 7; const _SortAction(this.option);
const int _actionFilterRooms = 8; }
const int _actionToggleUnreadOnly = 9;
const int _actionNewGroup = 10; class _TypeFilterAction extends _ContactsFilterAction {
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;
@@ -108,7 +112,6 @@ 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,
@@ -118,30 +121,29 @@ 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( return SortFilterMenu<_ContactsFilterAction>(
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: _actionSortRecentMessages, value: _SortAction(ContactSortOption.recentMessages),
label: l10n.listFilter_latestMessages, label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages, checked: sortOption == ContactSortOption.recentMessages,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortLastSeen, value: _SortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortName, value: _SortAction(ContactSortOption.name),
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -151,80 +153,66 @@ class ContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterAll, value: _TypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterFavorites, value: _TypeFilterAction(ContactTypeFilter.favorites),
label: l10n.listFilter_favorites, label: l10n.listFilter_favorites,
checked: typeFilter == ContactTypeFilter.favorites, checked: typeFilter == ContactTypeFilter.favorites,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterUsers, value: _TypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRepeaters, value: _TypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRooms, value: _TypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionToggleUnreadOnly, value: const _ToggleUnreadAction(),
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 _actionSortRecentMessages: case _SortAction(:final option):
onSortChanged(ContactSortOption.recentMessages); onSortChanged(option);
break; case _TypeFilterAction(:final filter):
case _actionSortName: onTypeFilterChanged(filter);
onSortChanged(ContactSortOption.name); case _ToggleUnreadAction():
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;
@@ -242,19 +230,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( return SortFilterMenu<_DiscoveryFilterAction>(
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: _actionSortLastSeen, value: _DiscoverySortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently, label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen, checked: sortOption == ContactSortOption.lastSeen,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionSortName, value: _DiscoverySortAction(ContactSortOption.name),
label: l10n.listFilter_az, label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name, checked: sortOption == ContactSortOption.name,
), ),
@@ -264,22 +252,22 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
title: l10n.listFilter_filters, title: l10n.listFilter_filters,
options: [ options: [
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterAll, value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all, label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all, checked: typeFilter == ContactTypeFilter.all,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterUsers, value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users, label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users, checked: typeFilter == ContactTypeFilter.users,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRepeaters, value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters, label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters, checked: typeFilter == ContactTypeFilter.repeaters,
), ),
SortFilterMenuOption( SortFilterMenuOption(
value: _actionFilterRooms, value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers, label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms, checked: typeFilter == ContactTypeFilter.rooms,
), ),
@@ -288,27 +276,10 @@ class DiscoveryContactsFilterMenu extends StatelessWidget {
], ],
onSelected: (action) { onSelected: (action) {
switch (action) { switch (action) {
case _actionSortName: case _DiscoverySortAction(:final option):
onSortChanged(ContactSortOption.name); onSortChanged(option);
break; case _DiscoveryTypeFilterAction(:final filter):
case _actionSortLastSeen: onTypeFilterChanged(filter);
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)
+3 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 6.0.0+7 version: 7.0.0+8
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -69,6 +69,8 @@ dependencies:
material_symbols_icons: ^4.2906.0 material_symbols_icons: ^4.2906.0
web: ^1.1.1 web: ^1.1.1
flutter_svg: ^2.0.10+1 flutter_svg: ^2.0.10+1
ml_algo: ^16.0.0
ml_dataframe: ^1.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ml_algo/ml_algo.dart';
import 'package:ml_dataframe/ml_dataframe.dart';
void main() {
test('LinearRegressor basic sanity check', () {
// Simple: y = 2x + 100
final data = DataFrame(
[
[1.0, 102.0],
[2.0, 104.0],
[3.0, 106.0],
[4.0, 108.0],
[5.0, 110.0],
[10.0, 120.0],
[20.0, 140.0],
[50.0, 200.0],
[0.0, 100.0],
[100.0, 300.0],
],
headerExists: false,
header: ['x', 'y'],
);
debugPrint('Training data columns: ${data.header}');
debugPrint('Training data rows: ${data.rows.length}');
final model = LinearRegressor(data, 'y');
final testDf = DataFrame(
[
[25.0],
],
headerExists: false,
header: ['x'],
);
final prediction = model.predict(testDf);
final value = prediction.rows.first.first;
debugPrint('Predict x=25 → y=$value (expected ~150)');
expect((value as num).toDouble(), closeTo(150, 5));
});
test('LinearRegressor multi-feature with constant column produces zeros', () {
// isFlood=0 for all rows zero-variance column singular matrix
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 14.0, 0.0, 2200.0],
[2.0, 50.0, 14.0, 0.0, 5000.0],
[4.0, 50.0, 14.0, 0.0, 9500.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
final testDf = DataFrame(
[
[2.0, 50.0, 14.0, 0.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'With constant isFlood column: hops=2 → ${(pred as num).round()}ms (likely 0)',
);
});
test('LinearRegressor 2-feature works correctly', () {
// Just pathLength + messageBytes deliveryMs
final data = DataFrame(
[
[0.0, 50.0, 1900.0],
[0.0, 80.0, 2200.0],
[2.0, 50.0, 5000.0],
[2.0, 80.0, 5500.0],
[4.0, 50.0, 9500.0],
[4.0, 80.0, 10000.0],
[0.0, 30.0, 1800.0],
[2.0, 30.0, 4800.0],
[4.0, 30.0, 9000.0],
[0.0, 60.0, 2000.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes', 'deliveryMs'],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final hops in [0.0, 2.0, 4.0]) {
final testDf = DataFrame(
[
[hops, 50.0],
],
headerExists: false,
header: ['pathLength', 'messageBytes'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint('2-feature: hops=$hops${(pred as num).round()}ms');
}
});
test('LinearRegressor multi-feature with variance in all columns', () {
// Mix flood and direct so isFlood has variance
final data = DataFrame(
[
[0.0, 50.0, 14.0, 0.0, 1900.0],
[0.0, 80.0, 10.0, 0.0, 2200.0],
[2.0, 50.0, 16.0, 0.0, 5000.0],
[2.0, 80.0, 20.0, 0.0, 5500.0],
[4.0, 50.0, 8.0, 0.0, 9500.0],
[4.0, 80.0, 12.0, 0.0, 10000.0],
[-1.0, 40.0, 14.0, 1.0, 5000.0],
[-1.0, 60.0, 18.0, 1.0, 6500.0],
[-1.0, 30.0, 10.0, 1.0, 4000.0],
[-1.0, 80.0, 22.0, 1.0, 7000.0],
],
headerExists: false,
header: [
'pathLength',
'messageBytes',
'hourOfDay',
'isFlood',
'deliveryMs',
],
);
final model = LinearRegressor(data, 'deliveryMs');
for (final tc in [
[0.0, 50.0, 14.0, 0.0],
[2.0, 50.0, 14.0, 0.0],
[4.0, 50.0, 14.0, 0.0],
[-1.0, 50.0, 14.0, 1.0],
]) {
final testDf = DataFrame(
[tc],
headerExists: false,
header: ['pathLength', 'messageBytes', 'hourOfDay', 'isFlood'],
);
final pred = model.predict(testDf).rows.first.first;
debugPrint(
'4-feature: hops=${tc[0]} flood=${tc[3]}${(pred as num).round()}ms',
);
}
});
}
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/delivery_observation.dart';
import 'package:meshcore_open/services/timeout_prediction_service.dart';
void main() {
late TimeoutPredictionService service;
setUp(() {
service = TimeoutPredictionService.noStorage();
});
test('trains on sample data and predicts sensible timeouts', () {
// Simulate realistic delivery data:
// Direct 0-hop messages: ~1500-2500ms
// 2-hop messages: ~4000-6000ms
// 4-hop messages: ~8000-12000ms
// Flood messages: ~3000-8000ms
final sampleData = [
// 0-hop direct
_obs(pathLength: 0, messageBytes: 20, deliveryMs: 1800),
_obs(pathLength: 0, messageBytes: 50, deliveryMs: 2100),
_obs(pathLength: 0, messageBytes: 80, deliveryMs: 2400),
_obs(pathLength: 0, messageBytes: 30, deliveryMs: 1925),
// 2-hop direct
_obs(pathLength: 2, messageBytes: 40, deliveryMs: 4500),
_obs(pathLength: 2, messageBytes: 60, deliveryMs: 5200),
_obs(pathLength: 2, messageBytes: 25, deliveryMs: 4100),
// 4-hop direct
_obs(pathLength: 4, messageBytes: 50, deliveryMs: 9800),
_obs(pathLength: 4, messageBytes: 30, deliveryMs: 8500),
_obs(pathLength: 4, messageBytes: 70, deliveryMs: 10570),
// Flood
_obs(pathLength: -1, messageBytes: 40, deliveryMs: 5000),
_obs(pathLength: -1, messageBytes: 60, deliveryMs: 6500),
];
// Feed all observations
for (final obs in sampleData) {
service.recordObservation(
contactKey: obs.contactKey,
pathLength: obs.pathLength,
messageBytes: obs.messageBytes,
tripTimeMs: obs.deliveryMs,
);
}
expect(service.hasModel, isTrue);
expect(service.observationCount, equals(12));
// Predict for different scenarios
final direct0 = service.predictTimeout(pathLength: 0, messageBytes: 50);
final direct2 = service.predictTimeout(pathLength: 2, messageBytes: 50);
final direct4 = service.predictTimeout(pathLength: 4, messageBytes: 50);
final flood = service.predictTimeout(pathLength: -1, messageBytes: 50);
// All should return non-null (model is trained)
expect(direct0, isNotNull);
expect(direct2, isNotNull);
expect(direct4, isNotNull);
expect(flood, isNotNull);
// More hops should predict longer timeouts
expect(direct4!, greaterThan(direct2!));
expect(direct2, greaterThan(direct0!));
// All should be positive
expect(direct0, greaterThan(0));
expect(direct4, greaterThan(0));
// Print predictions for visibility
debugPrint('Predictions (with 1.5x safety margin):');
debugPrint(' 0-hop direct: ${direct0}ms');
debugPrint(' 2-hop direct: ${direct2}ms');
debugPrint(' 4-hop direct: ${direct4}ms');
debugPrint(' flood: ${flood}ms');
});
test('returns null before minimum observations', () {
for (var i = 0; i < TimeoutPredictionService.minObservations - 1; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000,
);
}
expect(service.hasModel, isFalse);
expect(service.predictTimeout(pathLength: 0, messageBytes: 50), isNull);
});
test('caps observations at maxObservations', () {
for (var i = 0; i < TimeoutPredictionService.maxObservations + 20; i++) {
service.recordObservation(
contactKey: 'abc',
pathLength: 0,
messageBytes: 50,
tripTimeMs: 2000 + i,
);
}
expect(
service.observationCount,
equals(TimeoutPredictionService.maxObservations),
);
});
test('blends per-contact stats after enough observations', () {
// Train with mixed contacts and varied features:
// contactA is fast (0-hop), contactB is slow (2-hop)
for (var i = 0; i < 12; i++) {
service.recordObservation(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 30 + i,
tripTimeMs: 1500,
);
service.recordObservation(
contactKey: 'contactB',
pathLength: 2,
messageBytes: 30 + i,
tripTimeMs: 8000,
);
}
final predA = service.predictTimeout(
contactKey: 'contactA',
pathLength: 0,
messageBytes: 50,
);
final predB = service.predictTimeout(
contactKey: 'contactB',
pathLength: 0,
messageBytes: 50,
);
expect(predA, isNotNull);
expect(predB, isNotNull);
// Contact B (slow) should have a higher predicted timeout than A (fast)
expect(predB!, greaterThan(predA!));
debugPrint('Per-contact blending:');
debugPrint(' contactA (fast): ${predA}ms');
debugPrint(' contactB (slow): ${predB}ms');
});
}
DeliveryObservation _obs({
required int pathLength,
required int messageBytes,
required int deliveryMs,
String contactKey = 'test_contact',
}) {
return DeliveryObservation(
contactKey: contactKey,
pathLength: pathLength,
messageBytes: messageBytes,
secondsSinceLastRx: 5,
isFlood: pathLength < 0,
deliveryMs: deliveryMs,
timestamp: DateTime.now(),
);
}