Compare commits

..

34 Commits

Author SHA1 Message Date
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
63 changed files with 9102 additions and 1765 deletions

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
## Key PRs
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 112;
CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>112</string>
<string>115</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
</testcase>

View File

@@ -25,6 +25,7 @@ import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart';
import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart';
@@ -42,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
}
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale;
bool _isInitialized = false;
bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override
void initState() {
@@ -66,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions
await _checkLocationPermissions();
@@ -81,6 +85,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
});
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
}
/// Handle notification tap
@@ -89,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap
if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl);
return;
}
if (payload.startsWith('discovery:')) {
final navigator = _navigatorKey.currentState;
if (navigator == null) {
_pendingNotificationPayload = payload;
return;
}
navigator.push(
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
);
}
// SAR and message notifications handled by their respective providers
}
@@ -304,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
),

View File

@@ -4,6 +4,8 @@ class SavedContactGroup {
final String label;
final String query;
final DateTime createdAt;
final List<String>? matchPrefixes;
final bool isAutoGroup;
const SavedContactGroup({
required this.id,
@@ -11,6 +13,8 @@ class SavedContactGroup {
required this.label,
required this.query,
required this.createdAt,
this.matchPrefixes,
this.isAutoGroup = false,
});
SavedContactGroup copyWith({
@@ -19,6 +23,8 @@ class SavedContactGroup {
String? label,
String? query,
DateTime? createdAt,
List<String>? matchPrefixes,
bool? isAutoGroup,
}) {
return SavedContactGroup(
id: id ?? this.id,
@@ -26,6 +32,8 @@ class SavedContactGroup {
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
);
}
}

View File

@@ -66,6 +66,11 @@ class DeviceInfo {
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({
required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
this.receivedCopies = 1,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
'receivedCopies': receivedCopies,
};
}
MessageReceptionDetails copyWith({
DateTime? capturedAt,
DateTime? packetLoggedAt,
int? rssiDbm,
double? snrDb,
List<int>? pathBytes,
int? senderToReceiptMs,
int? estimatedTransmitMs,
int? postTransmitDelayMs,
int? receivedCopies,
}) {
return MessageReceptionDetails(
capturedAt: capturedAt ?? this.capturedAt,
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
rssiDbm: rssiDbm ?? this.rssiDbm,
snrDb: snrDb ?? this.snrDb,
pathBytes: pathBytes ?? this.pathBytes,
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
receivedCopies: receivedCopies ?? this.receivedCopies,
);
}
static MessageReceptionDetails mergeDuplicate({
MessageReceptionDetails? existing,
MessageReceptionDetails? incoming,
}) {
final base =
existing ??
incoming ??
MessageReceptionDetails(capturedAt: DateTime.now());
return base.copyWith(
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
snrDb: incoming?.snrDb ?? existing?.snrDb,
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
senderToReceiptMs:
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
estimatedTransmitMs:
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
postTransmitDelayMs:
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
receivedCopies:
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
);
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
@@ -12,14 +13,40 @@ import '../utils/key_comparison.dart';
class PendingAdvert {
final Uint8List publicKey;
final DateTime receivedAt;
final String? advName;
final int? typeValue;
final int? flags;
final int? lastAdvert;
final int? advLat;
final int? advLon;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
final int? rxRssiDbm;
final int? rxSnrRaw;
final int? repeaterBatteryMv;
final int? repeaterQueueLen;
final int? repeaterLastRssi;
final int? repeaterLastSnrRaw;
final int? repeaterUptimeSecs;
const PendingAdvert({
required this.publicKey,
required this.receivedAt,
this.advName,
this.typeValue,
this.flags,
this.lastAdvert,
this.advLat,
this.advLon,
this.signedEncodedPathLen,
this.paddedPathBytes,
this.rxRssiDbm,
this.rxSnrRaw,
this.repeaterBatteryMv,
this.repeaterQueueLen,
this.repeaterLastRssi,
this.repeaterLastSnrRaw,
this.repeaterUptimeSecs,
});
String get publicKeyHex =>
@@ -33,16 +60,55 @@ class PendingAdvert {
PendingAdvert copyWith({
Uint8List? publicKey,
DateTime? receivedAt,
String? advName,
int? typeValue,
int? flags,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
int? repeaterBatteryMv,
int? repeaterQueueLen,
int? repeaterLastRssi,
int? repeaterLastSnrRaw,
int? repeaterUptimeSecs,
}) {
return PendingAdvert(
publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt,
advName: advName ?? this.advName,
typeValue: typeValue ?? this.typeValue,
flags: flags ?? this.flags,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
);
}
double? get repeaterBatteryPercent {
if (repeaterBatteryMv == null) return null;
final voltage = repeaterBatteryMv! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
double? get repeaterLastSnr =>
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
}
class _RetainedRoute {
@@ -58,11 +124,16 @@ class _RetainedRoute {
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
static const String autoGroupIdPrefix = 'auto_group_';
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
// Add default public channel on initialization
ContactsProvider() {
@@ -82,6 +153,7 @@ class ContactsProvider with ChangeNotifier {
);
final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -98,6 +170,7 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups
..clear()
..addAll(storedGroups);
_restorePendingAdverts(storedPendingAdverts);
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
@@ -130,6 +203,7 @@ class ContactsProvider with ChangeNotifier {
excludePublicKey: devicePublicKey,
);
final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -146,6 +220,10 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups
..clear()
..addAll(storedGroups);
_restorePendingAdverts(
storedPendingAdverts,
devicePublicKey: devicePublicKey,
);
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
@@ -201,19 +279,38 @@ class ContactsProvider with ChangeNotifier {
}
}
/// Persist contacts to storage (async, non-blocking)
/// Persist contacts to storage (async, non-blocking, coalescing).
Future<void> _persistContacts() async {
_persistRequested = true;
if (_isPersisting) return;
_isPersisting = true;
try {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveContacts(_contactsForStorage());
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
} finally {
_isPersisting = false;
}
}
Future<void> _persistPendingAdverts() async {
_persistPendingAdvertsRequested = true;
if (_isPersistingPendingAdverts) return;
_isPersistingPendingAdverts = true;
try {
while (_persistPendingAdvertsRequested) {
_persistPendingAdvertsRequested = false;
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
);
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
} finally {
_isPersistingPendingAdverts = false;
}
}
@@ -225,6 +322,28 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return _pendingAdverts[keyHex];
}
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
final advert = pendingAdvertByKey(publicKey);
if (advert == null) {
return false;
}
final hasName = advert.advName?.trim().isNotEmpty ?? false;
final hasType = advert.typeValue != null && advert.typeValue != 0;
final hasLocation =
advert.advLat != null &&
advert.advLon != null &&
(advert.advLat != 0 || advert.advLon != 0);
return !(hasName && hasType && hasLocation);
}
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
@@ -248,6 +367,8 @@ class ContactsProvider with ChangeNotifier {
String sectionKey,
String query, {
String? label,
List<String>? matchPrefixes,
bool isAutoGroup = false,
}) async {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty ||
@@ -262,6 +383,8 @@ class ContactsProvider with ChangeNotifier {
label: (label ?? query).trim(),
query: query.trim(),
createdAt: DateTime.now(),
matchPrefixes: matchPrefixes,
isAutoGroup: isAutoGroup,
),
);
@@ -299,6 +422,18 @@ class ContactsProvider with ChangeNotifier {
notifyListeners();
}
Future<void> replaceAutoGroupsForSection(
String sectionKey,
List<SavedContactGroup> groups,
) async {
_savedContactGroups.removeWhere(
(group) => group.sectionKey == sectionKey && group.isAutoGroup,
);
_savedContactGroups.addAll(groups);
await _persistSavedGroups();
notifyListeners();
}
Future<void> _persistSavedGroups() async {
try {
await _storageService.saveContactGroups(_savedContactGroups);
@@ -418,11 +553,11 @@ class ContactsProvider with ChangeNotifier {
);
_contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex);
debugPrint(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
}
@@ -446,7 +581,6 @@ class ContactsProvider with ChangeNotifier {
incomingContact: contact,
existingContact: existingContact,
);
_pendingAdverts.remove(contact.publicKeyHex);
}
if (excluded > 0) {
debugPrint(
@@ -454,6 +588,7 @@ class ContactsProvider with ChangeNotifier {
);
}
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -514,6 +649,7 @@ class ContactsProvider with ChangeNotifier {
var updatedContact = incomingContact.copyWith(
isNew: false,
nameOverride: existingContact.nameOverride,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
outPathLen:
@@ -889,7 +1025,7 @@ class ContactsProvider with ChangeNotifier {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
for (final contact in _contacts.values) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
@@ -1023,40 +1159,153 @@ class ContactsProvider with ChangeNotifier {
notifyListeners();
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
void setContactNameOverride(String publicKeyHex, String? overrideName) {
final contact = _contacts[publicKeyHex];
if (contact == null) {
return;
}
final normalizedOverride = overrideName?.trim();
final nextOverride =
(normalizedOverride == null || normalizedOverride.isEmpty)
? null
: normalizedOverride;
if (contact.nameOverride == nextOverride) {
return;
}
_contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride);
_persistContacts();
notifyListeners();
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes only self key; known contacts still keep a discovery entry.
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex);
return;
}
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
if (existing != null) {
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
_persistPendingAdverts();
notifyListeners();
return false;
} else {
_pendingAdverts[keyHex] = PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
);
_persistPendingAdverts();
notifyListeners();
return true;
}
}
bool addOrUpdatePendingAdvertContact(
Contact contact, {
Uint8List? devicePublicKey,
}) {
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = contact.publicKeyHex;
final route = ContactRouteCodec.fromContact(contact);
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
final updated =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(contact.publicKey),
receivedAt: now,
))
.copyWith(
receivedAt: now,
advName: contact.advName.trim().isEmpty ? null : contact.advName,
typeValue: contact.type.value,
flags: contact.flags,
lastAdvert: contact.lastAdvert,
advLat: contact.advLat,
advLon: contact.advLon,
signedEncodedPathLen:
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: route?.paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(route!.paddedPathBytes),
);
_pendingAdverts[keyHex] = updated;
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
bool addOrUpdatePendingAdvertMetadata({
required Uint8List publicKey,
required int typeValue,
Uint8List? devicePublicKey,
int? flags,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
DateTime? receivedAt,
}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final existing = _pendingAdverts[keyHex];
final nextReceivedAt = receivedAt ?? DateTime.now();
_pendingAdverts[keyHex] =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: nextReceivedAt,
))
.copyWith(
receivedAt: nextReceivedAt,
typeValue: typeValue,
advName: advName?.trim().isNotEmpty == true
? advName!.trim()
: existing?.advName,
flags: flags ?? existing?.flags,
lastAdvert: lastAdvert ?? existing?.lastAdvert,
advLat: advLat ?? existing?.advLat,
advLon: advLon ?? existing?.advLon,
signedEncodedPathLen:
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(paddedPathBytes),
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
);
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
for (final contact in _contacts.values) {
if (contact.advName == name) return contact;
}
return null;
}
/// Get contacts with low battery
@@ -1105,7 +1354,9 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_ensurePublicChannelExists();
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -1128,6 +1379,7 @@ class ContactsProvider with ChangeNotifier {
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -1136,6 +1388,139 @@ class ContactsProvider with ChangeNotifier {
return await _storageService.getStorageStats();
}
Future<void> persistNow() async {
await _storageService.saveContacts(_contactsForStorage());
}
Future<void> clearPendingAdverts() async {
if (_pendingAdverts.isEmpty) {
return;
}
_pendingAdverts.clear();
await _storageService.clearPendingAdverts();
notifyListeners();
}
void updatePendingAdvertStatusByPrefix(
Uint8List publicKeyPrefix, {
int? batteryMv,
int? queueLen,
int? lastRssi,
int? lastSnrRaw,
int? uptimeSecs,
}) {
final prefixHex = publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final match = _pendingAdverts.entries.where(
(entry) => entry.key.startsWith(prefixHex),
);
if (match.isEmpty) {
return;
}
final key = match.first.key;
final existing = _pendingAdverts[key]!;
_pendingAdverts[key] = existing.copyWith(
repeaterBatteryMv: batteryMv,
repeaterQueueLen: queueLen,
repeaterLastRssi: lastRssi,
repeaterLastSnrRaw: lastSnrRaw,
repeaterUptimeSecs: uptimeSecs,
);
_persistPendingAdverts();
notifyListeners();
}
List<Contact> _contactsForStorage() {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
return _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
}
void _restorePendingAdverts(
List<Map<String, dynamic>> storedPendingAdverts, {
Uint8List? devicePublicKey,
}) {
_pendingAdverts.clear();
for (final json in storedPendingAdverts) {
final advert = _pendingAdvertFromJson(json);
if (advert == null) continue;
if (devicePublicKey != null &&
advert.publicKey.matches(devicePublicKey)) {
continue;
}
if (_contacts.containsKey(advert.publicKeyHex)) {
continue;
}
_pendingAdverts[advert.publicKeyHex] = advert;
}
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
return {
'publicKey': base64Encode(advert.publicKey),
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
'advName': advert.advName,
'typeValue': advert.typeValue,
'flags': advert.flags,
'lastAdvert': advert.lastAdvert,
'advLat': advert.advLat,
'advLon': advert.advLon,
'signedEncodedPathLen': advert.signedEncodedPathLen,
'paddedPathBytes': advert.paddedPathBytes == null
? null
: base64Encode(advert.paddedPathBytes!),
'rxRssiDbm': advert.rxRssiDbm,
'rxSnrRaw': advert.rxSnrRaw,
'repeaterBatteryMv': advert.repeaterBatteryMv,
'repeaterQueueLen': advert.repeaterQueueLen,
'repeaterLastRssi': advert.repeaterLastRssi,
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
};
}
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
try {
return PendingAdvert(
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int,
),
advName: json['advName'] as String?,
typeValue: json['typeValue'] as int?,
flags: json['flags'] as int?,
lastAdvert: json['lastAdvert'] as int?,
advLat: json['advLat'] as int?,
advLon: json['advLon'] as int?,
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
paddedPathBytes: json['paddedPathBytes'] == null
? null
: Uint8List.fromList(
base64Decode(json['paddedPathBytes'] as String),
),
rxRssiDbm: json['rxRssiDbm'] as int?,
rxSnrRaw: json['rxSnrRaw'] as int?,
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
repeaterQueueLen: json['repeaterQueueLen'] as int?,
repeaterLastRssi: json['repeaterLastRssi'] as int?,
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
);
} catch (e) {
debugPrint(
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
);
return null;
}
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {

View File

@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings
class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings';
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
static const String _showSarMarkersKey = 'map_show_sar_markers';
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
@@ -20,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -51,13 +54,16 @@ class DrawingProvider with ChangeNotifier {
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none;
bool get isInitialized => _isInitialized;
LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance;
/// Initialize and load saved drawings
Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings();
_isInitialized = true;
}
/// Set drawing mode
@@ -77,15 +83,30 @@ class DrawingProvider with ChangeNotifier {
}
/// Toggle visibility of received drawings
void toggleReceivedDrawings() {
Future<void> toggleReceivedDrawings() async {
_showReceivedDrawings = !_showReceivedDrawings;
notifyListeners();
await _savePreferences();
}
/// Toggle visibility of SAR markers
void toggleSarMarkers() {
Future<void> toggleSarMarkers() async {
_showSarMarkers = !_showSarMarkers;
notifyListeners();
await _savePreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
}
/// Start drawing a line

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
/// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {};
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
Completer<void>? _slotFreedCompleter;
/// Track a pending message ID before sending
///
/// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId);
}
_ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
}
/// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
}
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
_notifySlotFreed();
}
/// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7;
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
///
/// Returns immediately if not at the rate limit.
Future<void> waitForSlot({
Duration timeout = const Duration(milliseconds: 500),
}) async {
if (!shouldRateLimit) return;
_slotFreedCompleter ??= Completer<void>();
await _slotFreedCompleter!.future.timeout(
timeout,
onTimeout: () {},
);
}
void _notifySlotFreed() {
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
_slotFreedCompleter!.complete();
}
_slotFreedCompleter = null;
}
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
import '../models/map_drawing.dart';
class MapProvider with ChangeNotifier {
MapProvider() {
unawaited(_loadInitialState());
}
LatLng? _targetLocation;
double? _targetZoom;
bool _shouldAnimate = false;
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
bool _hideRepeatersOnMap = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail;
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
Future<void> _loadInitialState() async {
await Future.wait([loadOverlayState(), loadTrailSettings()]);
await loadRepeaterVisibilitySettings();
}
/// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
}
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
}
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
notifyListeners();
}
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) {
_importedTrail = trail;

View File

@@ -18,13 +18,20 @@ import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {};
final Set<String> _removedSarMarkerIds = <String>{};
final MessageStorageService _storageService = MessageStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
@@ -37,6 +44,8 @@ class MessagesProvider with ChangeNotifier {
// Track timeout timers for pending messages
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Timer> _channelEchoWarningTimers = {};
final Set<String> _channelEchoWarningMessageIds = <String>{};
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {};
@@ -125,6 +134,7 @@ class MessagesProvider with ChangeNotifier {
_messages.where((m) => m.isSystemMessage).toList();
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
Set<String> get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds);
List<SarMarker> get foundPersonMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
@@ -154,6 +164,9 @@ class MessagesProvider with ChangeNotifier {
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
_messageRouteMetadata[messageId];
bool hasChannelSendWarning(String messageId) =>
_channelEchoWarningMessageIds.contains(messageId);
void updateMessageRouteSelection(
String messageId,
PathSelection selection, {
@@ -244,6 +257,17 @@ class MessagesProvider with ChangeNotifier {
)
.length;
int getUnreadCountForChannel(int channelIdx) => _messages
.where(
(message) =>
message.isChannelMessage &&
(message.channelIdx ?? 0) == channelIdx &&
!message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage,
)
.length;
bool _isMessageForDestination(Message message, Contact contact) {
if (message.isSystemMessage) return false;
@@ -298,6 +322,8 @@ class MessagesProvider with ChangeNotifier {
.loadMessageTransferDetails();
final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata();
final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
@@ -310,6 +336,9 @@ class MessagesProvider with ChangeNotifier {
_messageRouteMetadata
..clear()
..addAll(storedRouteMetadata);
_removedSarMarkerIds
..clear()
..addAll(storedRemovedSarMarkerIds);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -357,7 +386,7 @@ class MessagesProvider with ChangeNotifier {
// Extract SAR markers
if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker();
if (marker != null) {
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker;
}
}
@@ -498,6 +527,7 @@ class MessagesProvider with ChangeNotifier {
);
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage;
@@ -523,6 +553,15 @@ class MessagesProvider with ChangeNotifier {
}
}
final incomingPathBytes = receptionDetailsSnapshot?.pathBytes;
if (incomingPathBytes != null &&
incomingPathBytes.isNotEmpty &&
finalMessage.pathBytes == null) {
finalMessage = finalMessage.copyWith(
pathBytes: Uint8List.fromList(incomingPathBytes),
);
}
// Debug: Check if message is SAR
if (message.text.startsWith('S:')) {
debugPrint(
@@ -537,29 +576,40 @@ class MessagesProvider with ChangeNotifier {
// - Mesh network retransmissions
// - Multiple paths in the network
// - Syncing messages from device queue
if (_isDuplicate(finalMessage)) {
final duplicateIndex = _findDuplicateMessageIndex(finalMessage);
if (duplicateIndex != -1) {
debugPrint(
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
);
debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
);
final existingIndex = _messages.indexWhere(
(existing) =>
existing.messageType == finalMessage.messageType &&
existing.senderTimestamp == finalMessage.senderTimestamp &&
existing.text == finalMessage.text,
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
final existingId = _messages[duplicateIndex].id;
if (finalMessage.isChannelMessage &&
!finalMessage.isSentMessage &&
_messages[duplicateIndex].isSentMessage) {
_clearChannelSendWarning(existingId);
}
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
if (receptionDetailsSnapshot != null) {
final existingMessage = _messages[duplicateIndex];
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
if (existingMessage.pathBytes == null &&
duplicatePathBytes != null &&
duplicatePathBytes.isNotEmpty) {
_messages[duplicateIndex] = existingMessage.copyWith(
pathBytes: Uint8List.fromList(duplicatePathBytes),
);
}
}
_persistMessages();
return; // Skip duplicate
}
@@ -574,7 +624,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) {
final marker = finalMessage.toSarMarker();
if (marker != null) {
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker;
// Trigger urgent notification for received SAR messages (not sent by user)
@@ -598,50 +648,101 @@ class MessagesProvider with ChangeNotifier {
/// Messages are considered duplicates if they have:
/// 1. Same sender public key prefix (for contact messages)
/// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp
/// 4. Same text content
/// 3. Same text content
///
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
/// because they can be retried with different message IDs
bool _isDuplicate(Message message) {
int _findDuplicateMessageIndex(Message message) {
// Sent messages (our own messages) should never be considered duplicates
// They can be retried multiple times with different IDs
if (message.isSentMessage) {
return -1;
}
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) ||
existing.text != message.text) {
continue;
}
if (message.isChannelMessage) {
debugPrint(
'🔁 [MessagesProvider] Channel duplicate match: '
'incoming(id=${message.id}, senderName=${message.senderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, pathLen=${message.pathLen}, ts=${message.senderTimestamp}) '
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, pathLen=${existing.pathLen}, ts=${existing.senderTimestamp})',
);
}
return index;
}
return -1;
}
bool _matchesDuplicateScope(Message existing, Message message) {
if (existing.messageType != message.messageType) {
return false;
}
return _messages.any((existing) {
// Check message type matches
if (existing.messageType != message.messageType) {
if (message.isContactMessage) {
return existing.senderKeyShort == message.senderKeyShort;
}
if (message.isChannelMessage) {
if (existing.channelIdx != message.channelIdx) {
return false;
}
// Check sender matches
if (message.isContactMessage) {
// For contact messages, compare sender public key prefix
if (existing.senderKeyShort != message.senderKeyShort) {
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey &&
withinChannelRepeatWindow) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existing.isChannelMessage && message.isChannelMessage) {
debugPrint(
'🧪 [MessagesProvider] Channel dedupe check: '
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, resolvedSender=${existingSenderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, ts=${existing.senderTimestamp}, pathLen=${existing.pathLen}) '
'incoming(id=${message.id}, sent=${message.isSentMessage}, senderName=${message.senderName ?? "-"}, resolvedSender=${incomingSenderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, ts=${message.senderTimestamp}, pathLen=${message.pathLen}) '
'withinWindow=$withinChannelRepeatWindow',
);
}
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
if (!existing.isSentMessage && !message.isSentMessage) {
return withinChannelRepeatWindow;
}
if (!withinChannelRepeatWindow) {
return false;
}
} else if (message.isChannelMessage) {
// For channel messages, compare channel index
if (existing.channelIdx != message.channelIdx) {
return false;
// Mirror meshcore-open behavior for self-authored channel messages:
// zero-hop self replays are dropped earlier in AppProvider, while
// routed self replays should fold into the local sent bubble when the
// public sender name matches within a short window.
if (existing.isSentMessage &&
!message.isSentMessage &&
existingSenderName == incomingSenderName) {
return true;
}
return true;
}
// Check timestamp matches (sender timestamp is the unique identifier from the sender)
if (existing.senderTimestamp != message.senderTimestamp) {
return false;
}
return false;
}
// Check text content matches
if (existing.text != message.text) {
return false;
}
// All criteria match - this is a duplicate
return true;
});
// System messages and other types: never deduplicate by scope alone.
return false;
}
/// Add multiple messages
@@ -651,10 +752,42 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) {
// Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
var enhancedMessage = SarMessageParser.enhanceMessage(message);
// Check if it's a drawing message (D:...) and not already marked
if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) &&
!enhancedMessage.isDrawing) {
final drawing = DrawingMessageParser.parseDrawingMessage(
enhancedMessage.text,
senderName: enhancedMessage.senderName,
messageId: enhancedMessage.id,
);
enhancedMessage = enhancedMessage.copyWith(
isDrawing: true,
drawingId: drawing?.id,
);
}
// Check if it's a voice message and not already marked
if (!enhancedMessage.isVoice) {
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
if (envelope != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: envelope.sessionId,
);
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates
if (_isDuplicate(enhancedMessage)) {
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
);
duplicateCount++;
continue; // Skip duplicate
}
@@ -664,7 +797,7 @@ class MessagesProvider with ChangeNotifier {
if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker();
if (marker != null) {
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker;
}
}
@@ -680,6 +813,80 @@ class MessagesProvider with ChangeNotifier {
notifyListeners();
}
String? _normalizeSenderName(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return null;
}
var normalized = trimmed.toLowerCase();
if (normalized.startsWith('meshcore-')) {
normalized = normalized.substring('meshcore-'.length).trim();
}
return normalized.isEmpty ? null : normalized;
}
Message _resolveSenderNameIfNeeded(Message message) {
if (message.senderName != null || message.senderPublicKeyPrefix == null) {
return message;
}
final resolvedSenderName = resolveContactNameCallback?.call(
message.senderPublicKeyPrefix,
);
if (resolvedSenderName == null || resolvedSenderName.trim().isEmpty) {
return message;
}
return message.copyWith(senderName: resolvedSenderName.trim());
}
String? _normalizedResolvedSenderName(Message message) {
final directName = _normalizeSenderName(message.senderName);
if (directName != null) {
return directName;
}
if (message.senderPublicKeyPrefix == null) {
return null;
}
final resolved = resolveContactNameCallback?.call(
message.senderPublicKeyPrefix,
);
return _normalizeSenderName(resolved);
}
void _scheduleChannelEchoWarning(String messageId) {
_channelEchoWarningTimers[messageId]?.cancel();
_channelEchoWarningMessageIds.remove(messageId);
_channelEchoWarningTimers[messageId] = Timer(_channelEchoWarningDelay, () {
final index = _messages.indexWhere((m) => m.id == messageId);
_channelEchoWarningTimers.remove(messageId);
if (index == -1) {
return;
}
final message = _messages[index];
if (!message.isChannelMessage ||
!message.isSentMessage ||
message.deliveryStatus != MessageDeliveryStatus.sent ||
message.echoCount > 0) {
return;
}
_channelEchoWarningMessageIds.add(messageId);
notifyListeners();
});
}
void _clearChannelSendWarning(String messageId) {
_channelEchoWarningTimers[messageId]?.cancel();
_channelEchoWarningTimers.remove(messageId);
_channelEchoWarningMessageIds.remove(messageId);
}
/// Trigger urgent notification for SAR marker
Future<void> _triggerSarNotification(
Message message,
@@ -825,18 +1032,31 @@ class MessagesProvider with ChangeNotifier {
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
}
/// Persist messages to storage (async, non-blocking)
/// Persist messages to storage (async, non-blocking, coalescing).
///
/// Multiple rapid calls are coalesced into a single write to avoid
/// overlapping serialization and redundant SharedPreferences writes.
Future<void> _persistMessages() async {
_persistRequested = true;
if (_isPersisting) {
return; // A write is in flight; it will pick up our changes.
}
_isPersisting = true;
try {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
}
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} finally {
_isPersisting = false;
}
}
@@ -859,6 +1079,55 @@ class MessagesProvider with ChangeNotifier {
.toList();
}
/// Remove all messages associated with a specific channel.
void clearChannelMessages(int channelIdx) {
final messageIdsToRemove = _messages
.where(
(message) =>
message.isChannelMessage && message.channelIdx == channelIdx,
)
.map((message) => message.id)
.toList();
if (messageIdsToRemove.isEmpty) {
return;
}
_messages.removeWhere(
(message) => message.isChannelMessage && message.channelIdx == channelIdx,
);
for (final messageId in messageIdsToRemove) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
_clearChannelSendWarning(messageId);
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
_messageRouteMetadata.remove(messageId);
}
_pendingSentMessages.removeWhere(
(_, message) =>
message.isChannelMessage && message.channelIdx == channelIdx,
);
_messageAckHistory.removeWhere(
(messageId, _) => messageIdsToRemove.contains(messageId),
);
_ackHistoryLookup.removeWhere(
(_, value) => messageIdsToRemove.contains(value.$1),
);
debugPrint(
'🗑️ [MessagesProvider] Cleared ${messageIdsToRemove.length} message(s) for channel $channelIdx',
);
_persistMessages();
notifyListeners();
}
/// Get recent messages (last N messages)
List<Message> getRecentMessages({int count = 50}) {
final sorted = List<Message>.from(_messages)
@@ -866,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
return sorted.take(count).toList();
}
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
final entries = <DisplayMessageEntry>[];
for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere(
(entry) =>
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
);
if (existingIndex == -1) {
entries.add((message: message, occurrenceCount: occurrenceCount));
continue;
}
final existingEntry = entries[existingIndex];
entries[existingIndex] = (
message: existingEntry.message,
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
);
}
return entries;
}
int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
/// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration);
@@ -886,17 +1184,35 @@ class MessagesProvider with ChangeNotifier {
return _sarMarkers[id];
}
Message? getMessageById(String id) {
final index = _messages.indexWhere((message) => message.id == id);
if (index == -1) return null;
return _messages[index];
}
/// Get recent SAR markers (within last hour)
List<SarMarker> getRecentSarMarkers() {
return sarMarkers.where((m) => m.isRecent).toList();
}
/// Remove a SAR marker
void removeSarMarker(String id) {
Future<void> removeSarMarker(String id) async {
_sarMarkers.remove(id);
_removedSarMarkerIds.add(id);
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners();
}
Future<void> removeSarMarkerPermanently(String id) async {
final hasBackingMessage = _messages.any((message) => message.id == id);
if (hasBackingMessage) {
deleteMessage(id);
return;
}
await removeSarMarker(id);
}
/// Mark all messages as read
void markAllAsRead() {
bool hasChanges = false;
@@ -984,6 +1300,7 @@ class MessagesProvider with ChangeNotifier {
final marker = message.toSarMarker();
if (marker != null) {
_sarMarkers.remove(marker.id);
_removedSarMarkerIds.add(marker.id);
}
}
@@ -993,6 +1310,7 @@ class MessagesProvider with ChangeNotifier {
// Cancel timeout timer if it exists
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
_clearChannelSendWarning(message.id);
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
@@ -1006,6 +1324,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
}
}
@@ -1028,31 +1347,55 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages
void clearMessages() {
_cancelAllTimers();
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
}
/// Clear all SAR markers
void clearSarMarkers() {
Future<void> clearSarMarkers() async {
_removedSarMarkerIds.addAll(_sarMarkers.keys);
_sarMarkers.clear();
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners();
}
/// Clear all data
void clearAll() {
_cancelAllTimers();
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
}
@@ -1211,9 +1554,10 @@ class MessagesProvider with ChangeNotifier {
);
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_isDuplicate(enhancedMessage)) {
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
);
@@ -1238,7 +1582,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker();
if (marker != null) {
if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
debugPrint(' ✅ SAR Marker created:');
debugPrint(' marker.id: ${marker.id}');
debugPrint(' marker.notes: "${marker.notes}"');
@@ -1457,6 +1801,9 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
' Channel message (no ACK tracking) - marked as sent immediately',
);
if (message.isChannelMessage) {
_scheduleChannelEchoWarning(messageId);
}
}
debugPrint(' Calling notifyListeners() to update UI with "sent" status');
@@ -1505,6 +1852,7 @@ class MessagesProvider with ChangeNotifier {
lastEchoAt: DateTime.now(),
);
_messages[index] = updatedMessage;
_clearChannelSendWarning(messageId);
debugPrint(' Updated echo count to: $echoCount');
_persistMessages();
@@ -1726,7 +2074,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Message index in list: $index');
if (index != -1) {
final updatedMessage = message.copyWith(
final updatedMessage = _messages[index].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(),
@@ -1880,6 +2228,7 @@ class MessagesProvider with ChangeNotifier {
}
final message = _messages[index];
_clearChannelSendWarning(messageId);
final contact = _messageContactMap[messageId];
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
@@ -1919,6 +2268,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
_clearChannelSendWarning(messageId);
// Cancel old timeout timer
_timeoutTimers[message.id]?.cancel();
@@ -1984,6 +2334,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
_clearChannelSendWarning(messageId);
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
@@ -2082,6 +2433,7 @@ class MessagesProvider with ChangeNotifier {
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx,
pathLen: message.pathLen,
pathBytes: message.pathBytes,
textType: message.textType,
senderTimestamp: message.senderTimestamp,
text: message.text,
@@ -2110,6 +2462,7 @@ class MessagesProvider with ChangeNotifier {
isVoice: message.isVoice,
voiceId: message.voiceId,
);
_clearChannelSendWarning(messageId);
_persistMessages();
notifyListeners();
@@ -2168,11 +2521,7 @@ class MessagesProvider with ChangeNotifier {
@override
void dispose() {
// Cancel all pending timeout timers
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_cancelAllTimers();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
@@ -2183,6 +2532,19 @@ class MessagesProvider with ChangeNotifier {
super.dispose();
}
/// Cancel all pending timers (timeout + echo warning).
void _cancelAllTimers() {
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
}
void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory();

View File

@@ -2,12 +2,14 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/map_provider.dart';
import '../providers/messages_provider.dart';
import '../services/message_destination_preferences.dart';
import '../utils/contact_grouping.dart';
@@ -32,8 +34,6 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingPendingBatch = false;
final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '',
ContactSection.repeaters: '',
@@ -97,57 +97,6 @@ class _ContactsTabState extends State<ContactsTab> {
await _getCurrentLocation();
}
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
void _schedulePendingAdvertResolution(
List<PendingAdvert> pendingAdverts,
ConnectionProvider connectionProvider,
) {
if (_isResolvingPendingBatch ||
!connectionProvider.deviceInfo.isConnected ||
pendingAdverts.isEmpty) {
return;
}
final advertsToResolve = pendingAdverts
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
.toList();
if (advertsToResolve.isEmpty) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted || _isResolvingPendingBatch) return;
_isResolvingPendingBatch = true;
try {
for (final advert in advertsToResolve) {
if (!mounted) break;
await _handleResolveAdvert(advert);
}
} finally {
_isResolvingPendingBatch = false;
}
});
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(
double lat1,
@@ -179,15 +128,6 @@ class _ContactsTabState extends State<ContactsTab> {
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
List<Contact> _filterContactsForSection(
List<Contact> contacts,
ContactSection section,
@@ -200,7 +140,9 @@ class _ContactsTabState extends State<ContactsTab> {
return contacts.where((contact) {
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(query) || advertisedName.contains(query);
return name.contains(query) ||
advertisedName.contains(query) ||
ContactGrouping.contactMatchesInferredGroupLabel(contact, query);
}).toList();
}
@@ -213,7 +155,19 @@ class _ContactsTabState extends State<ContactsTab> {
final name = contact.displayName.toLowerCase();
final advertisedName = contact.advName.toLowerCase();
return name.contains(normalizedQuery) ||
advertisedName.contains(normalizedQuery);
advertisedName.contains(normalizedQuery) ||
ContactGrouping.contactMatchesInferredGroupLabel(
contact,
normalizedQuery,
);
}
bool _sectionHasActiveFilter(ContactSection section) {
return (_sectionFilters[section] ?? '').trim().isNotEmpty;
}
bool _showSavedGroupsForSection(ContactSection section) {
return !_sectionHasActiveFilter(section);
}
List<_RenderedSavedGroup> _buildSavedGroupsForSection(
@@ -225,7 +179,7 @@ class _ContactsTabState extends State<ContactsTab> {
.savedGroupsForSection(section.name)
.map((group) {
final matches = contacts
.where((contact) => _contactMatchesFilter(contact, group.query))
.where((contact) => _contactMatchesSavedGroup(contact, group))
.toList();
return _RenderedSavedGroup(group: group, contacts: matches);
})
@@ -238,6 +192,23 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
bool _contactMatchesSavedGroup(Contact contact, SavedContactGroup group) {
final matchPrefixes = group.matchPrefixes;
if (matchPrefixes != null && matchPrefixes.isNotEmpty) {
final inferredLabel = ContactGrouping.inferredGroupLabelForContact(
contact,
)?.toLowerCase();
if (inferredLabel == null) {
return false;
}
return matchPrefixes.any(
(prefix) => prefix.toLowerCase() == inferredLabel,
);
}
return _contactMatchesFilter(contact, group.query);
}
Future<void> _toggleSavedGroupForSection(
BuildContext context,
ContactsProvider contactsProvider,
@@ -278,6 +249,48 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
Future<void> _createAutoGroupsForSection(
BuildContext context,
ContactsProvider contactsProvider,
ContactSection section,
List<Contact> contacts, {
int? maxNamedGroups,
String? overflowGroupLabel,
String emptyMessage = 'No auto groups available',
String successMessage = 'Updated auto groups',
}) async {
final inferredGroups = ContactGrouping.inferGroups(
contacts,
maxNamedGroups: maxNamedGroups,
overflowGroupLabel: overflowGroupLabel,
);
final now = DateTime.now();
final groups = inferredGroups
.map(
(group) => SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}${section.name}_${group.key}',
sectionKey: section.name,
label: group.label,
query: group.label,
createdAt: now,
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await contactsProvider.replaceAutoGroupsForSection(section.name, groups);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(groups.isEmpty ? emptyMessage : successMessage)),
);
}
List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
@@ -326,41 +339,186 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
/// Show the add channel dialog
/// Show the add channel sheet
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
await showDialog(
await showModalBottomSheet(
context: context,
builder: (context) => AddChannelDialog(
onCreateChannel: (name, secret) async {
final connectionProvider = context.read<ConnectionProvider>();
try {
await connectionProvider.createChannel(
channelName: name,
channelSecret: secret,
);
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: AddChannelSheet(
onCreateChannel: (name, secret) async {
final connectionProvider = context.read<ConnectionProvider>();
try {
await connectionProvider.createChannel(
channelName: name,
channelSecret: secret,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreatedSuccessfully),
backgroundColor: Colors.green,
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreatedSuccessfully),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreationFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
rethrow; // Re-throw to let dialog handle the error state
}
},
),
),
),
);
}
Future<void> _showDeleteChannelDialog(
BuildContext context,
Contact channel,
) async {
if (channel.isPublicChannel) {
return;
}
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.deleteChannel),
content: Text(l10n.deleteChannelConfirmation(channel.advName)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !context.mounted) {
return;
}
try {
final channelIdx = channel.publicKey.length > 1
? channel.publicKey[1]
: 0;
await context.read<ConnectionProvider>().deleteChannel(channelIdx);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelDeletedSuccessfully),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelDeletionFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
Future<void> _openMessagesForChannel(
BuildContext context,
Contact channel,
) async {
final messagesProvider = context.read<MessagesProvider>();
await MessageDestinationPreferences.setDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKey: channel.publicKeyHex,
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channel.publicKeyHex,
);
widget.onNavigateToMessages?.call();
}
void _showChannelOnMap(BuildContext context, Contact channel) {
final location = channel.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: LatLng(location.latitude, location.longitude),
);
widget.onNavigateToMap?.call();
}
void _showChannelActionSheet(BuildContext context, Contact channel) {
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.message_outlined),
title: Text(l10n.messages),
onTap: () async {
Navigator.pop(sheetContext);
await _openMessagesForChannel(context, channel);
},
),
if (channel.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showChannelOnMap(context, channel);
},
),
if (!channel.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
l10n.deleteChannel,
style: const TextStyle(color: Colors.red),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.channelCreationFailed(e.toString())),
backgroundColor: Colors.red,
),
);
}
rethrow; // Re-throw to let dialog handle the error state
}
},
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
_showDeleteChannelDialog(context, channel);
});
},
),
],
),
),
);
}
@@ -373,7 +531,6 @@ class _ContactsTabState extends State<ContactsTab> {
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts(
contactsProvider.chatContacts,
ContactSection.teamMembers,
@@ -399,6 +556,10 @@ class _ContactsTabState extends State<ContactsTab> {
allChatContacts,
ContactSection.teamMembers,
);
final visibleSavedTeamGroups =
_showSavedGroupsForSection(ContactSection.teamMembers)
? savedTeamGroups
: const <_RenderedSavedGroup>[];
final repeaters = _filterContactsForSection(
allRepeaters,
ContactSection.repeaters,
@@ -408,6 +569,17 @@ class _ContactsTabState extends State<ContactsTab> {
allRepeaters,
ContactSection.repeaters,
);
final visibleSavedRepeaterGroups =
_showSavedGroupsForSection(ContactSection.repeaters)
? savedRepeaterGroups
: const <_RenderedSavedGroup>[];
final ungroupedRepeaters = _excludeGroupedContacts(
repeaters,
visibleSavedRepeaterGroups,
);
final showRepeatersOthersGroup =
visibleSavedRepeaterGroups.length > 1 &&
ungroupedRepeaters.isNotEmpty;
final rooms = _filterContactsForSection(
allRooms,
ContactSection.rooms,
@@ -417,6 +589,10 @@ class _ContactsTabState extends State<ContactsTab> {
allRooms,
ContactSection.rooms,
);
final visibleSavedRoomGroups =
_showSavedGroupsForSection(ContactSection.rooms)
? savedRoomGroups
: const <_RenderedSavedGroup>[];
final filteredChannels = _filterContactsForSection(
allChannels,
ContactSection.channels,
@@ -426,17 +602,20 @@ class _ContactsTabState extends State<ContactsTab> {
allChannels,
ContactSection.channels,
);
final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
final visibleSavedChannelGroups =
_showSavedGroupsForSection(ContactSection.channels)
? savedChannelGroups
: const <_RenderedSavedGroup>[];
final showTeamMembersSection = allChatContacts.isNotEmpty;
final showRepeatersSection = allRepeaters.isNotEmpty;
final showRoomsSection = allRooms.isNotEmpty;
final showChannelsSection = allChannels.isNotEmpty;
// Check if there are any displayable contacts
final hasDisplayableContacts =
allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty ||
allRooms.isNotEmpty ||
allChannels.isNotEmpty ||
pendingAdverts.isNotEmpty;
allChannels.isNotEmpty;
if (!hasDisplayableContacts) {
return Center(
@@ -470,7 +649,7 @@ class _ContactsTabState extends State<ContactsTab> {
padding: const EdgeInsets.all(8),
children: [
// Team Members (Chat contacts)
if (allChatContacts.isNotEmpty) ...[
if (showTeamMembersSection) ...[
_SectionHeader(
title: l10n.teamMembers,
count: chatContacts.length,
@@ -484,23 +663,36 @@ class _ContactsTabState extends State<ContactsTab> {
context,
ContactSection.teamMembers,
contactsProvider,
),
if (chatContacts.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedTeamGroups,
onSecondaryAction: () => _createAutoGroupsForSection(
context,
contactsProvider,
ContactSection.teamMembers,
allChatContacts,
emptyMessage: 'No contact auto groups available',
successMessage: 'Updated contact auto groups',
),
secondaryActionIcon: Icons.auto_awesome_outlined,
secondaryActionTooltip: 'Auto group',
),
..._buildSavedGroupCards(
visibleSavedTeamGroups,
ContactSection.teamMembers,
),
if (chatContacts.isEmpty &&
_sectionHasActiveFilter(ContactSection.teamMembers))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(chatContacts, savedTeamGroups),
_excludeGroupedContacts(
chatContacts,
visibleSavedTeamGroups,
),
),
],
const Divider(height: 32),
],
// Repeaters
if (allRepeaters.isNotEmpty) ...[
if (showRepeatersSection) ...[
_SectionHeader(
title: l10n.repeaters,
count: repeaters.length,
@@ -511,23 +703,48 @@ class _ContactsTabState extends State<ContactsTab> {
context,
ContactSection.repeaters,
contactsProvider,
),
if (repeaters.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedRepeaterGroups,
onSecondaryAction: () => _createAutoGroupsForSection(
context,
contactsProvider,
ContactSection.repeaters,
allRepeaters,
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
emptyMessage: 'No repeater auto groups available',
successMessage: 'Updated repeater auto groups',
),
secondaryActionIcon: Icons.auto_awesome_outlined,
secondaryActionTooltip: 'Auto group',
),
..._buildSavedGroupCards(
visibleSavedRepeaterGroups,
ContactSection.repeaters,
),
if (repeaters.isEmpty &&
_sectionHasActiveFilter(ContactSection.repeaters))
_buildNoFilterResults(context)
else if (showRepeatersOthersGroup)
_InferredContactGroupCard(
label: 'Others',
contacts: ungroupedRepeaters,
kindLabel: 'Auto group',
compactContacts: true,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
)
else
..._buildContactSectionItems(
_excludeGroupedContacts(repeaters, savedRepeaterGroups),
ungroupedRepeaters,
compact: true,
),
],
const Divider(height: 32),
],
// Rooms
if (allRooms.isNotEmpty) ...[
if (showRoomsSection) ...[
_SectionHeader(
title: l10n.rooms,
count: rooms.length,
@@ -539,70 +756,52 @@ class _ContactsTabState extends State<ContactsTab> {
ContactSection.rooms,
contactsProvider,
),
if (rooms.isEmpty)
_buildEmptyFilterState(context)
else ...[
..._buildSavedGroupCards(
savedRoomGroups,
ContactSection.rooms,
),
..._buildSavedGroupCards(
visibleSavedRoomGroups,
ContactSection.rooms,
),
if (rooms.isEmpty &&
_sectionHasActiveFilter(ContactSection.rooms))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, savedRoomGroups),
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
),
],
const Divider(height: 32),
],
// Pending adverts are kept below resolved sections while we load details.
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_search,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
),
),
const Divider(height: 32),
],
// Channels (visible in both simple and advanced mode)
_SectionHeader(
title: l10n.channels,
count: filteredChannels.length,
icon: Icons.broadcast_on_personal,
),
_buildSectionFilterField(
context,
ContactSection.channels,
contactsProvider,
),
if (allChannels.isNotEmpty && filteredChannels.isEmpty)
_buildEmptyFilterState(context),
..._buildSavedGroupCards(
savedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isNotEmpty) ...[
..._excludeGroupedContacts(
filteredChannels,
savedChannelGroups,
).map(
(channel) => _ChannelActivityCard(
channel: channel,
messagesProvider: messagesProvider,
contactsProvider: contactsProvider,
onNavigateToMessages: widget.onNavigateToMessages,
),
if (showChannelsSection) ...[
_SectionHeader(
title: l10n.channels,
count: filteredChannels.length,
icon: Icons.broadcast_on_personal,
),
_buildSectionFilterField(
context,
ContactSection.channels,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isEmpty &&
_sectionHasActiveFilter(ContactSection.channels))
_buildNoFilterResults(context)
else ...[
..._excludeGroupedContacts(
filteredChannels,
visibleSavedChannelGroups,
).map(
(channel) => _ChannelActivityCard(
channel: channel,
messagesProvider: messagesProvider,
contactsProvider: contactsProvider,
onTap: () => _showChannelActionSheet(context, channel),
),
),
],
],
// Add Channel Button (visible in both simple and advanced mode, only show when connected)
@@ -632,31 +831,35 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
final items = ContactGrouping.buildItemsFromSorted(contacts);
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
return items.map((item) {
if (item.isGroup) {
return _InferredContactGroupCard(
label: item.group!.label,
contacts: item.group!.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}
return ContactTile(
contact: item.contact!,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}).toList();
List<Widget> _buildContactSectionItems(
List<Contact> contacts, {
bool compact = false,
}) {
return contacts
.map(
(contact) => ContactTile(
contact: contact,
compact: compact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
)
.toList();
}
List<Contact> _excludeGroupedContacts(
@@ -682,6 +885,7 @@ class _ContactsTabState extends State<ContactsTab> {
(group) => _InferredContactGroupCard(
label: group.group.label,
contacts: group.contacts,
compactContacts: section == ContactSection.repeaters,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
@@ -690,7 +894,7 @@ class _ContactsTabState extends State<ContactsTab> {
onDelete: () => context
.read<ContactsProvider>()
.removeSavedGroupById(group.group.id),
kindLabel: 'Saved filter',
kindLabel: group.group.isAutoGroup ? 'Auto group' : 'Saved filter',
),
)
.toList();
@@ -699,8 +903,11 @@ class _ContactsTabState extends State<ContactsTab> {
Widget _buildSectionFilterField(
BuildContext context,
ContactSection section,
ContactsProvider contactsProvider,
) {
ContactsProvider contactsProvider, {
VoidCallback? onSecondaryAction,
IconData? secondaryActionIcon,
String? secondaryActionTooltip,
}) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final controller = _filterControllers[section]!;
@@ -784,6 +991,30 @@ class _ContactsTabState extends State<ContactsTab> {
),
),
if (hasFilter) ...[
if (onSecondaryAction != null &&
secondaryActionIcon != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Tooltip(
message: secondaryActionTooltip ?? '',
child: Material(
color: colorScheme.tertiary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSecondaryAction,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
secondaryActionIcon,
size: 16,
color: colorScheme.tertiary,
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
@@ -840,7 +1071,37 @@ class _ContactsTabState extends State<ContactsTab> {
),
),
] else
const SizedBox(width: 12),
Row(
children: [
if (onSecondaryAction != null &&
secondaryActionIcon != null)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Tooltip(
message: secondaryActionTooltip ?? '',
child: Material(
color: colorScheme.tertiary.withValues(
alpha: 0.10,
),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onSecondaryAction,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
secondaryActionIcon,
size: 16,
color: colorScheme.tertiary,
),
),
),
),
),
),
const SizedBox(width: 12),
],
),
],
),
),
@@ -850,18 +1111,6 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
Widget _buildEmptyFilterState(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'No matches for this filter.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
@@ -925,46 +1174,6 @@ enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
final bool isResolving;
final VoidCallback onResolve;
const _PendingAdvertTile({
required this.advert,
required this.subtitle,
required this.isResolving,
required this.onResolve,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
title: Text(
advert.shortDisplayKey,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(subtitle),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
onPressed: onResolve,
),
),
);
}
}
class _RenderedSavedGroup {
final SavedContactGroup group;
final List<Contact> contacts;
@@ -1022,6 +1231,7 @@ class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final String? kindLabel;
final bool compactContacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
@@ -1033,6 +1243,7 @@ class _InferredContactGroupCard extends StatelessWidget {
required this.label,
required this.contacts,
this.kindLabel,
this.compactContacts = false,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
@@ -1119,6 +1330,7 @@ class _InferredContactGroupCard extends StatelessWidget {
(contact) => ContactTile(
contact: contact,
groupLabel: label,
compact: compactContacts,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
@@ -1137,13 +1349,13 @@ class _ChannelActivityCard extends StatelessWidget {
final Contact channel;
final MessagesProvider messagesProvider;
final ContactsProvider contactsProvider;
final VoidCallback? onNavigateToMessages;
final VoidCallback? onTap;
const _ChannelActivityCard({
required this.channel,
required this.messagesProvider,
required this.contactsProvider,
required this.onNavigateToMessages,
this.onTap,
});
String _formatRelativeTime(BuildContext context, DateTime when) {
@@ -1207,17 +1419,7 @@ class _ChannelActivityCard extends StatelessWidget {
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () async {
await MessageDestinationPreferences.setDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKey: channel.publicKeyHex,
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channel.publicKeyHex,
);
onNavigateToMessages?.call();
},
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(

View File

@@ -1,8 +1,15 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../models/channel.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/validation_service.dart';
import '../l10n/app_localizations.dart';
@@ -14,6 +21,11 @@ class DeviceConfigScreen extends StatefulWidget {
}
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
static const int _bulkDeleteBatchSize = 8;
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
static const int _autoAddFilterModeFlag = 1;
static const List<_RadioPreset> _radioPresets = [
_RadioPreset(
id: 'australia',
@@ -166,16 +178,30 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _lonController;
late TextEditingController _freqController;
late TextEditingController _txPowerController;
late final ConnectionProvider _connectionProvider;
bool _telemetryEnabled = false;
bool _repeatEnabled = false;
bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true;
bool _autoAddRoomServersEnabled = true;
bool _autoAddSensorsEnabled = true;
bool _overwriteOldestAutoAddEnabled = false;
bool _showCustomRadioSettings = false;
bool _isSavingPublicInfo = false;
bool _isSavingRadioSettings = false;
bool _isSavingAutoDiscoverySettings = false;
bool _isClearingContacts = false;
bool _isClearingChannels = false;
bool _publicInfoSaved = false;
bool _radioSettingsSaved = false;
bool _autoDiscoverySettingsSaved = false;
bool _autoDiscoverySettingsDirty = false;
String? _lastAutoDiscoverySignature;
String? _publicInfoError;
String? _radioSettingsError;
String? _autoDiscoverySettingsError;
String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8;
@@ -197,7 +223,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
final deviceInfo = _connectionProvider.deviceInfo;
_nameController = TextEditingController(
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
@@ -252,6 +280,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false;
_syncAutoDiscoveryState(deviceInfo);
_lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo);
// Fetch allowed repeat frequencies on open if device supports repeat mode
if (deviceInfo.clientRepeat != null &&
@@ -262,12 +292,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConnectionProvider>().getBatteryAndStorage();
_connectionProvider.getBatteryAndStorage();
unawaited(_connectionProvider.getAutoaddConfig());
});
}
@override
void dispose() {
_connectionProvider.removeListener(_handleConnectionProviderChanged);
_nameController.dispose();
_latController.dispose();
_lonController.dispose();
@@ -342,6 +374,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
});
}
void _syncRadioPresetSelection() {
final freqResult = ValidationService().parseFrequency(_freqController.text);
final matchedPreset = freqResult.isSuccess
? _matchRadioPreset(
frequencyKhz: (freqResult.value! * 1000).round(),
bandwidth: _bandwidthToValue(_selectedBandwidth),
spreadingFactor: _selectedSpreadingFactor,
codingRate: _selectedCodingRate,
)
: null;
_selectedRadioPreset = matchedPreset;
_showCustomRadioSettings = matchedPreset == null;
}
void _markPublicInfoDirty() {
if (_publicInfoSaved || _publicInfoError != null) {
setState(() {
@@ -360,9 +407,83 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
void _markAutoDiscoverySettingsDirty() {
_autoDiscoverySettingsDirty = true;
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
setState(() {
_autoDiscoverySettingsSaved = false;
_autoDiscoverySettingsError = null;
});
}
}
String _autoDiscoverySignature(DeviceInfo deviceInfo) {
return [
deviceInfo.manualAddContacts,
deviceInfo.autoAddUsers,
deviceInfo.autoAddRepeaters,
deviceInfo.autoAddRoomServers,
deviceInfo.autoAddSensors,
deviceInfo.autoAddOverwriteOldest,
].join('|');
}
void _handleConnectionProviderChanged() {
if (!mounted || _autoDiscoverySettingsDirty) return;
final deviceInfo = _connectionProvider.deviceInfo;
final nextSignature = _autoDiscoverySignature(deviceInfo);
if (nextSignature == _lastAutoDiscoverySignature) return;
_lastAutoDiscoverySignature = nextSignature;
setState(() {
_syncAutoDiscoveryState(deviceInfo);
});
}
bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) {
return (deviceInfo.autoAddUsers ?? false) ||
(deviceInfo.autoAddRepeaters ?? false) ||
(deviceInfo.autoAddRoomServers ?? false) ||
(deviceInfo.autoAddSensors ?? false);
}
void _syncAutoDiscoveryState(DeviceInfo deviceInfo) {
final hasFetchedAutoAddConfig =
deviceInfo.autoAddUsers != null ||
deviceInfo.autoAddRepeaters != null ||
deviceInfo.autoAddRoomServers != null ||
deviceInfo.autoAddSensors != null ||
deviceInfo.autoAddOverwriteOldest != null;
_autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig
? _hasAutoAddTargetsEnabled(deviceInfo)
: !(deviceInfo.manualAddContacts ?? false);
_autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true;
_autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true;
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
}
int _telemetryModesForSave(ConnectionProvider connectionProvider) {
final deviceInfo = connectionProvider.deviceInfo;
final telemetryEnabled =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
}
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) {
final deviceInfo = connectionProvider.deviceInfo;
final telemetryEnabled =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0);
}
Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService();
setState(() {
@@ -410,7 +531,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 1,
);
@@ -421,7 +542,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00;
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 0,
);
@@ -522,6 +643,64 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
Future<void> _saveAutoDiscoverySettings() async {
final connectionProvider = context.read<ConnectionProvider>();
final autoAddUsers =
_autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled;
final autoAddRepeaters =
_autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled;
final autoAddRoomServers =
_autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled;
final autoAddSensors =
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
final overwriteOldest =
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
setState(() {
_isSavingAutoDiscoverySettings = true;
_autoDiscoverySettingsSaved = false;
_autoDiscoverySettingsError = null;
});
try {
await connectionProvider.setAutoaddConfig(
autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest,
);
await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: _telemetryModesForSave(connectionProvider),
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider),
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0,
);
await connectionProvider.getAutoaddConfig();
if (mounted) {
setState(() {
_syncAutoDiscoveryState(connectionProvider.deviceInfo);
_lastAutoDiscoverySignature = _autoDiscoverySignature(
connectionProvider.deviceInfo,
);
_isSavingAutoDiscoverySettings = false;
_autoDiscoverySettingsSaved = true;
_autoDiscoverySettingsDirty = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isSavingAutoDiscoverySettings = false;
_autoDiscoverySettingsError = AppLocalizations.of(
context,
)!.failedToSave(e.toString());
});
}
}
}
Future<void> _useCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
@@ -610,6 +789,267 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
Future<void> _confirmFactoryReset() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Wipe device data'),
content: const Text(
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Wipe device'),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
try {
await connectionProvider.factoryResetDevice();
if (!mounted) return;
if (connectionProvider.error != null) {
messenger.showSnackBar(
SnackBar(
content: Text(connectionProvider.error!),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
return;
}
messenger.showSnackBar(
const SnackBar(
content: Text(
'Factory reset command sent. The device should reboot and disconnect shortly.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to wipe device data: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Future<void> _confirmClearAllContacts() async {
final contacts = context
.read<ContactsProvider>()
.contacts
.where((contact) => !contact.isChannel)
.toList();
if (contacts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No device contacts to clear.')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear all contacts'),
content: Text(
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear contacts'),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingContacts = true;
});
try {
var processed = 0;
for (final contact in List<Contact>.from(contacts)) {
await contactsProvider.removeContact(
contact.publicKeyHex,
onRemoveFromDevice: connectionProvider.removeContact,
);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Flush the updated local contact set to storage immediately.
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.getContacts();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear contacts: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingContacts = false;
});
}
}
}
Future<void> _confirmClearAllChannels() async {
final channels = context
.read<ChannelsProvider>()
.channels
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
.toList();
if (channels.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No custom channels to clear.')),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear all channels'),
content: Text(
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear channels'),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final channelsProvider = context.read<ChannelsProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingChannels = true;
});
try {
var processed = 0;
for (final channel in List<Channel>.from(channels)) {
await connectionProvider.deleteChannel(channel.index);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Force local channel/contact cache cleanup before the device resync.
for (final channel in channels) {
channelsProvider.removeChannel(channel.index);
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF;
publicKeyBytes[1] = channel.index;
final publicKeyHex = publicKeyBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
await contactsProvider.removeContact(publicKeyHex);
}
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.syncChannels();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear channels: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingChannels = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
@@ -704,6 +1144,164 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: 'Auto discovery',
subtitle:
'Control how the radio auto-adds discovered nodes to its contacts table.',
icon: Icons.person_search_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingHighlightCard(
icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1
: Icons.person_add_disabled,
title: 'Enable automatic adding',
description:
'Turn this off to keep discoveries manual-only on the radio.',
accentColor: _autoAddDiscoveredContactsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddDiscoveredContactsEnabled,
onChanged: (value) {
setState(() {
_autoAddDiscoveredContactsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
},
),
),
const SizedBox(height: 18),
_SettingHighlightCard(
icon: Icons.person_outline_rounded,
title: 'Auto-add users',
description:
'Automatically store discovered user/chat nodes.',
accentColor: _autoAddUsersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddUsersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddUsersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.router_outlined,
title: 'Auto-add repeaters',
description:
'Automatically store discovered repeater nodes.',
accentColor: _autoAddRepeatersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRepeatersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRepeatersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.meeting_room_outlined,
title: 'Auto-add room servers',
description:
'Automatically store discovered room/server nodes.',
accentColor: _autoAddRoomServersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRoomServersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRoomServersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.sensors_outlined,
title: 'Auto-add sensors',
description:
'Automatically store discovered sensor nodes.',
accentColor: _autoAddSensorsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddSensorsEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddSensorsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.history_toggle_off_rounded,
title: 'Overwrite oldest when full',
description:
'Allow the radio to replace the oldest contact when storage is full.',
accentColor: _overwriteOldestAutoAddEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _overwriteOldestAutoAddEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_overwriteOldestAutoAddEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 18),
if (_autoDiscoverySettingsError != null) ...[
Text(
_autoDiscoverySettingsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 10),
],
SizedBox(
width: double.infinity,
child: _SaveActionButton(
onPressed: _isSavingAutoDiscoverySettings
? null
: _saveAutoDiscoverySettings,
isSaving: _isSavingAutoDiscoverySettings,
isSaved: _autoDiscoverySettingsSaved,
label: 'Save discovery settings',
),
),
],
),
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo,
subtitle: 'Choose the name and location this device shares.',
@@ -840,6 +1438,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<_RadioPreset?>(
key: ValueKey(_selectedRadioPreset?.id ?? 'custom'),
initialValue: _selectedRadioPreset,
decoration: InputDecoration(
labelText: 'Radio preset',
@@ -931,16 +1530,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
decimal: true,
),
onChanged: (_) {
if (_selectedRadioPreset != null) {
setState(() {
_selectedRadioPreset = null;
});
}
setState(_syncRadioPresetSelection);
_markRadioSettingsDirty();
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: ValueKey('bandwidth-$_selectedBandwidth'),
initialValue: _selectedBandwidth,
decoration: InputDecoration(
labelText: AppLocalizations.of(
@@ -962,7 +1558,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (newValue != null) {
setState(() {
_selectedBandwidth = newValue;
_selectedRadioPreset = null;
_syncRadioPresetSelection();
});
_markRadioSettingsDirty();
}
@@ -970,6 +1566,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
key: ValueKey(
'spreading-factor-$_selectedSpreadingFactor',
),
initialValue: _selectedSpreadingFactor,
decoration: InputDecoration(
labelText: AppLocalizations.of(
@@ -993,7 +1592,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (newValue != null) {
setState(() {
_selectedSpreadingFactor = newValue;
_selectedRadioPreset = null;
_syncRadioPresetSelection();
});
_markRadioSettingsDirty();
}
@@ -1001,6 +1600,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
key: ValueKey('coding-rate-$_selectedCodingRate'),
initialValue: _selectedCodingRate,
decoration: InputDecoration(
labelText: AppLocalizations.of(
@@ -1024,7 +1624,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (newValue != null) {
setState(() {
_selectedCodingRate = newValue;
_selectedRadioPreset = null;
_syncRadioPresetSelection();
});
_markRadioSettingsDirty();
}
@@ -1099,6 +1699,124 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
],
),
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: 'Danger zone',
subtitle: 'Destructive device actions.',
icon: Icons.warning_amber_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(
alpha: 0.55,
),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: colorScheme.error.withValues(alpha: 0.28),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.delete_forever_rounded,
color: colorScheme.error,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wipe data on device',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colorScheme.onErrorContainer,
),
),
const SizedBox(height: 4),
Text(
'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllContacts,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingContacts
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.people_alt_outlined),
label: const Text('Clear all contacts'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllChannels,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingChannels
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.forum_outlined),
label: const Text('Clear all channels'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmFactoryReset,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
minimumSize: const Size.fromHeight(52),
),
icon: const Icon(Icons.delete_forever_rounded),
label: const Text('Wipe device data'),
),
),
],
),
),
],
),
),

View File

@@ -0,0 +1,660 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:typed_data';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
}
class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _repeaterAdvertType = 2;
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
bool _isResolvingAll = false;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
}
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
}
Future<void> _clearAllDiscoveries() async {
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
if (pendingCount == 0) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear discoveries'),
content: Text(
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear all'),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
await context.read<ContactsProvider>().clearPendingAdverts();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cleared pending discoveries.')),
);
}
Future<void> _discoverNodeType(int advertType) async {
if (_runningDiscoveryTypes.contains(advertType)) return;
setState(() {
_runningDiscoveryTypes.add(advertType);
});
try {
await context.read<ConnectionProvider>().discoverNodeType(
advertType: advertType,
);
if (!mounted) return;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_ => 'Discovery sent',
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(label)));
} finally {
if (mounted) {
setState(() {
_runningDiscoveryTypes.remove(advertType);
});
}
}
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
await connectionProvider.getContact(advert.publicKey);
if (connectionProvider.error == 'Not found') {
connectionProvider.clearError();
final fallbackContact = _contactFromPendingAdvert(advert);
await connectionProvider.addOrUpdateContact(fallbackContact);
contactsProvider.addOrUpdateContact(
fallbackContact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
}
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
await connectionProvider.requestTelemetry(advert.publicKey);
}
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
if (_isResolvingAll || adverts.isEmpty) return;
setState(() {
_isResolvingAll = true;
});
try {
for (final advert in adverts) {
if (!mounted) break;
await _resolveAdvert(advert);
}
} finally {
if (mounted) {
setState(() {
_isResolvingAll = false;
});
}
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
Contact _contactFromPendingAdvert(PendingAdvert advert) {
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
final advName = advert.advName?.trim().isNotEmpty == true
? advert.advName!.trim()
: advert.shortDisplayKey;
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.fromValue(advert.typeValue ?? 0),
flags: advert.flags ?? 0,
outPathLen: advert.signedEncodedPathLen ?? -1,
outPath: advert.paddedPathBytes == null
? Uint8List(64)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
String _displayNameForAdvert(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final advertisedName = advert.advName?.trim();
if (advertisedName != null && advertisedName.isNotEmpty) {
return advertisedName;
}
Contact? existingMatch;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) {
existingMatch = contact;
break;
}
}
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
return existingMatch.displayName;
}
MeshMapNode? cachedMatch;
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
cachedMatch = node;
break;
}
}
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
return cachedMatch.name;
}
return advert.shortDisplayKey;
}
IconData _iconForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => Icons.router_outlined,
_sensorAdvertType => Icons.sensors_outlined,
_ => Icons.campaign_outlined,
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
1 => 'Chat',
_ => null,
};
}
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
_ => null,
};
}
}
return null;
}
Widget _buildAdvertTitle(
BuildContext context, {
required String displayName,
String? subtitle,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Discovery'),
actions: [
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected;
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
return PopupMenuButton<_DiscoveryMenuAction>(
tooltip: 'Discovery tools',
onSelected: _handleMenuAction,
itemBuilder: (context) => [
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.repeaters,
enabled: isConnected && !repeatersBusy,
child: Row(
children: [
repeatersBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
const SizedBox(width: 12),
const Text('Discover repeaters'),
],
),
),
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.sensors,
enabled: isConnected && !sensorsBusy,
child: Row(
children: [
sensorsBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
const SizedBox(width: 12),
const Text('Discover sensors'),
],
),
),
],
);
},
),
],
),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
'Pending discoveries (${pendingAdverts.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.download_for_offline_outlined,
),
label: const Text('Resolve all'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear all'),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (pendingAdverts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Unknown adverts will appear here until you choose to resolve them.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex,
);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(
advert,
cachedNodes,
);
final downMetric = SignalMetric.fromValues(
rssiDbm: advert.rxRssiDbm,
snrDb: advert.rxSnr,
);
final upMetric = SignalMetric.fromValues(
rssiDbm: advert.repeaterLastRssi,
snrDb: advert.repeaterLastSnr,
);
final detailLines = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
final battery = advert.repeaterBatteryPercent;
if (battery != null) {
summaryParts.add('Battery ${battery.round()}%');
}
if (advert.repeaterQueueLen != null) {
summaryParts.add('Queue ${advert.repeaterQueueLen}');
}
if (summaryParts.isNotEmpty) {
detailLines.add(summaryParts.join(''));
}
detailLines.add(
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
child: Icon(_iconForAdvert(advert)),
),
const SizedBox(width: 12),
Expanded(
child: _buildAdvertTitle(
context,
displayName: displayName,
subtitle: typeLabel,
),
),
if (downMetric != null || upMetric != null) ...[
const SizedBox(width: 12),
_buildSignalSummary(
context,
downMetric: downMetric,
upMetric: upMetric,
),
],
const SizedBox(width: 8),
isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(
Icons.person_add_alt_1,
),
tooltip: 'Resolve contact',
onPressed: isConnected
? () => _resolveAdvert(advert)
: null,
),
],
),
const SizedBox(height: 10),
Text(
detailLines.join('\n'),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}),
],
);
},
),
),
);
}
Widget _buildSignalSummary(
BuildContext context, {
SignalMetric? downMetric,
SignalMetric? upMetric,
}) {
if (downMetric == null && upMetric == null) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (downMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.south_west_rounded,
metric: downMetric,
),
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
if (upMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.north_east_rounded,
metric: upMetric,
),
],
);
}
Widget _buildDirectionalSignal(
BuildContext context, {
required IconData icon,
required SignalMetric metric,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 3),
_buildMiniSignalBars(context, metric),
const SizedBox(width: 4),
Text(
metric.valueLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 2),
Container(
width: 3,
height: 6.0 + (index * 4),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
);
}
}

View File

@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
@@ -753,6 +787,9 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
return MessagesTab(
isActive:
_currentTab == _HomeTab.messages &&
_lifecycleState == AppLifecycleState.resumed,
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,

View File

@@ -11,6 +11,8 @@ import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
T? _maybeProvider<T>(BuildContext context) {
@@ -134,11 +136,12 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[],
);
final packetTypes = unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final packetTypes =
unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final filteredEntries = snapshot.visibleEntries;
return Scaffold(
@@ -202,7 +205,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
Icon(
Icons.radar_rounded,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.45),
color: theme.colorScheme.primary.withValues(
alpha: 0.45,
),
),
const SizedBox(height: 16),
const Text(
@@ -218,7 +223,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
@@ -345,7 +352,7 @@ class _SummaryPanel extends StatelessWidget {
label: 'RX packets',
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? 'Last 60 sec'
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
@@ -488,7 +495,9 @@ class _FilterChip extends StatelessWidget {
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
@@ -504,12 +513,15 @@ class _FilterChip extends StatelessWidget {
}
String _windowLabel(Duration duration) {
if (duration.inMinutes >= 60) {
return '${duration.inMinutes} min';
}
return '${duration.inMinutes} min';
}
String _windowSummaryLabel(Duration duration) {
final minutes = duration.inMinutes;
if (minutes == 1) return 'Last 1 min';
return 'Last $minutes min';
}
class _MetricTile extends StatelessWidget {
final String label;
final String value;
@@ -527,7 +539,7 @@ class _MetricTile extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
width: 160,
height: 128,
height: 136,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
@@ -638,103 +650,103 @@ class _LiveTrafficCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final routePreview = _resolvedRoutePreview(context, entry);
final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.payloadLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetDetails.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (signalMetric != null) ...[
const SizedBox(width: 12),
CompactSignalIndicator(metric: signalMetric),
] else
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
),
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 10),
_PacketInfoLine(
text:
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PacketMetaChip(
label: '${log.rawData.length} bytes',
onTap: () => _showPacketBytesSheet(context, log.rawData),
),
if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null)
_PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
),
if (entry.hopCount != null)
_PacketMetaChip(label: '${entry.hopCount} hops'),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
if (packetDetails.pathLine != null)
_PacketInfoLine(text: packetDetails.pathLine!),
if (packetDetails.pathHashLine != null)
_PacketInfoLine(text: packetDetails.pathHashLine!),
if (packetDetails.endpointLine != null)
_PacketInfoLine(text: packetDetails.endpointLine!),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
_PacketInfoLine(text: 'Origin: $originDistance'),
],
),
const SizedBox(height: 10),
Text(
routePreview,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -745,36 +757,11 @@ class _LiveTrafficCard extends StatelessWidget {
return '${diff.inMinutes}m ago';
}
static String _resolvedRoutePreview(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return entry.routePreview;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
if (contactsProvider == null && connectionProvider == null) {
return entry.routePreview;
}
final ownLatLng = _ownLatLng(connectionProvider);
final resolvedLabels = route.hopHashes.map((hashHex) {
final resolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
return _compactNodeLabel(resolved);
}).toList();
return resolvedLabels.join(' -> ');
static String _formatClock(DateTime timestamp) {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
static String? _originDistanceLabel(
@@ -853,11 +840,16 @@ class _LiveTrafficCard extends StatelessWidget {
final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
hexLines.add(
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
);
}
final ascii = data
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
.map(
(byte) =>
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
)
.join();
return showModalBottomSheet<void>(
@@ -883,10 +875,7 @@ class _LiveTrafficCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text(
'Hex',
style: Theme.of(context).textTheme.titleSmall,
),
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -906,10 +895,7 @@ class _LiveTrafficCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text(
'ASCII',
style: Theme.of(context).textTheme.titleSmall,
),
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -937,17 +923,190 @@ class _LiveTrafficCard extends StatelessWidget {
);
}
static String _compactNodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return node.label;
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
if (node.matchCount > 0) {
return node.label;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {
final String title;
final String packetHashHex;
final String? pathLine;
final String? pathHashLine;
final String? endpointLine;
const _LiveTrafficPacketDetails({
required this.title,
required this.packetHashHex,
required this.pathLine,
required this.pathHashLine,
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
);
final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST',
0x01 => 'FLOOD RESPONSE',
0x02 => 'FLOOD TEXT',
0x03 => 'FLOOD ACK',
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
};
final hopHashes = route?.hopHashes ?? const <String>[];
final pathLine = hopHashes.isEmpty
? null
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
final pathHashLine = route == null
? null
: 'Path Hashes: ${route.hashSize}-byte per hop';
return _LiveTrafficPacketDetails(
title: title,
packetHashHex: _packetHash(entry.log.rawData),
pathLine: pathLine,
pathHashLine: pathHashLine,
endpointLine: parsedPayload?.endpointLine,
);
}
static String _packetHash(List<int> bytes) {
const fnvOffset = 0xcbf29ce484222325;
const fnvPrime = 0x100000001b3;
const mask = 0xFFFFFFFFFFFFFFFF;
var hash = fnvOffset;
for (final byte in bytes) {
hash ^= byte & 0xFF;
hash = (hash * fnvPrime) & mask;
}
return node.hexLabel;
return hash.toRadixString(16).padLeft(16, '0').toUpperCase();
}
}
class _ParsedTrafficPayload {
final String? endpointLine;
const _ParsedTrafficPayload({this.endpointLine});
static _ParsedTrafficPayload? tryParse(
List<int> rawData,
DecodedLogRxRoute? route,
) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null;
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathDescriptor = rawPacketData[index++];
final pathByteLen =
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
(pathDescriptor == 0xFF ? 0 : null);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
index += pathByteLen;
final payload = rawPacketData.sublist(index);
final senderHash = route?.hopHashes.isNotEmpty == true
? route!.hopHashes.first
: null;
switch (payloadType) {
case 0x05:
case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload();
return _ParsedTrafficPayload(
endpointLine:
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
);
case 0x00:
case 0x01:
case 0x07:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final destinationHash = payload.first
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return _ParsedTrafficPayload(
endpointLine: senderHash == null
? 'To: <$destinationHash>'
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
);
default:
return const _ParsedTrafficPayload();
}
}
}
class _PacketInfoLine extends StatelessWidget {
final String text;
const _PacketInfoLine({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _GeoPoint {
final double latitude;
final double longitude;
@@ -955,44 +1114,6 @@ class _GeoPoint {
const _GeoPoint(this.latitude, this.longitude);
}
class _PacketMetaChip extends StatelessWidget {
final String label;
final bool emphasized;
final VoidCallback? onTap;
const _PacketMetaChip({
required this.label,
this.emphasized = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = emphasized ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
);
}
}
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push(
context,

View File

@@ -22,6 +22,7 @@ import '../models/message.dart';
import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart';
import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
@@ -816,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}
}
Future<void> _showSarMarkerActions(
SarMarker marker,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
final message = messagesProvider.getMessageById(marker.id);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 10),
Expanded(
child: Text(
marker.displayName,
style: theme.textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
marker.senderName != null
? '${marker.timeAgo}${marker.senderName}'
: marker.timeAgo,
style: theme.textTheme.bodySmall,
),
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(marker.notes!),
],
const SizedBox(height: 16),
if (message != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Open message'),
subtitle: const Text('Jump to the related SAR message'),
onTap: () async {
Navigator.pop(sheetContext);
await _openSarMarkerMessage(
message,
messagesProvider,
contactsProvider,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text(
'Remove marker',
style: TextStyle(color: theme.colorScheme.error),
),
subtitle: Text(
message != null
? 'This also removes the linked SAR message.'
: 'Hide this marker from the map.',
),
onTap: () async {
final confirmed = await _confirmSarMarkerRemoval(
hasMessage: message != null,
);
if (!mounted ||
!sheetContext.mounted ||
confirmed != true) {
return;
}
Navigator.pop(sheetContext);
await messagesProvider.removeSarMarkerPermanently(
marker.id,
);
},
),
],
),
),
);
},
);
}
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove SAR marker'),
content: Text(
hasMessage
? 'This will remove the marker and its linked chat message.'
: 'This will hide the marker from the map, even if it is not visible in chat.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(AppLocalizations.of(dialogContext)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(dialogContext)!.delete),
),
],
),
);
}
Future<void> _openSarMarkerMessage(
Message message,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
if (message.isChannelMessage) {
final channelContact = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == (message.channelIdx ?? 0);
}).firstOrNull;
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channelContact?.publicKeyHex,
);
} else {
Contact? destinationContact;
if (message.recipientPublicKey != null) {
destinationContact = contactsProvider.contacts.where((contact) {
return contact.publicKey.length >=
message.recipientPublicKey!.length &&
contact.publicKey.matches(message.recipientPublicKey!);
}).firstOrNull;
} else if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6) {
destinationContact = contactsProvider.findContactByPrefix(
message.senderPublicKeyPrefix!,
);
}
if (destinationContact != null) {
messagesProvider.navigateToDestination(
destinationContact.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact,
recipientPublicKeyHex: destinationContact.publicKeyHex,
);
}
}
messagesProvider.navigateToMessage(message.id);
widget.onNavigateToMessages?.call();
}
/// Show SAR dialog with pre-populated location from map long press
void _showSarDialogWithLocation(LatLng location) {
// Create a Position object from the LatLng coordinates
@@ -1119,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
final contactsWithLocation = contactsProvider.contactsWithLocation;
return Consumer4<
ContactsProvider,
MessagesProvider,
DrawingProvider,
MapProvider
>(
builder: (
context,
contactsProvider,
messagesProvider,
drawingProvider,
mapProvider,
child,
) {
final allContactsWithLocation = contactsProvider.contactsWithLocation;
final contactsWithLocation = mapProvider.hideRepeatersOnMap
? allContactsWithLocation
.where((contact) => !contact.isRepeater)
.toList()
: allContactsWithLocation;
// Filter SAR markers based on visibility toggle
final allSarMarkers = messagesProvider.sarMarkers;
final sarMarkers = drawingProvider.showSarMarkers
@@ -1719,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
onTap: (contact) {
_showDetailedCompassWithContact(
context,
contactsProvider.contactsWithLocation,
contactsWithLocation,
messagesProvider.sarMarkers,
contact,
);
@@ -1731,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context,
mapRotation: _getMapRotation(),
onTap: (marker) {
// Navigate to the corresponding message in Messages tab
messagesProvider.navigateToMessage(marker.id);
widget.onNavigateToMessages?.call();
_showSarMarkerActions(
marker,
messagesProvider,
contactsProvider,
);
},
),
// User location marker with directional pointer
@@ -2021,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: GestureDetector(
onTap: () => _showDetailedCompass(
context,
contactsProvider.contactsWithLocation,
contactsWithLocation,
messagesProvider.sarMarkers,
),
child: CompassWidget(

View File

@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
final bool isActive;
const MessagesTab({super.key, this.onNavigateToMap});
const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
@override
State<MessagesTab> createState() => _MessagesTabState();
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148;
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
int _messageByteCount = 0;
String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state
String _destinationType =
@@ -85,7 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
@@ -115,14 +124,24 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void dispose() {
_highlightTimer?.cancel();
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant MessagesTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
}
}
void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
@@ -144,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId);
final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list
@@ -177,12 +200,130 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _handleComposerChanged() {
_updateCharacterCount();
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() {
setState(() {
_messageByteCount = utf8.encode(_textController.text).length;
});
}
void _handleFocusChanged() {
if (!_focusNode.hasFocus) {
_clearMentionSuggestions();
return;
}
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null;
}
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
return contacts.take(8).toList(growable: false);
}
int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes
@@ -338,13 +479,13 @@ class _MessagesTabState extends State<MessagesTab> {
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName) {
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = value.selection;
final selection = replacementRange ?? value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
@@ -355,18 +496,30 @@ class _MessagesTabState extends State<MessagesTab> {
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_suppressMentionTrigger = true;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit();
}
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String? senderDisplayName;
String destinationType;
Contact? recipient;
@@ -391,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
final roomRecipient = message.recipientPublicKey == null
? null
: contactsProvider.findContactByKey(message.recipientPublicKey!);
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
if (roomRecipient?.isRoom == true) {
destinationType = MessageDestinationPreferences.destinationTypeRoom;
recipient = roomRecipient;
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus();
}
@@ -467,6 +640,9 @@ class _MessagesTabState extends State<MessagesTab> {
return;
}
_textController.clear();
_focusNode.unfocus();
try {
// Check destination type and send accordingly
if (_destinationType ==
@@ -497,13 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
}
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return;
} catch (e) {
if (!mounted) return;
if (_textController.text.isEmpty) {
_textController.text = text;
_textController.selection = TextSelection.collapsed(
offset: _textController.text.length,
);
_focusNode.requestFocus();
}
ToastLogger.error(context, 'Failed to send: $e');
}
}
@@ -1712,12 +1893,64 @@ class _MessagesTabState extends State<MessagesTab> {
}
void _markCurrentDestinationAsRead() {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
if (!widget.isActive ||
_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
if (unreadCount <= 0) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final nextKey = '$channelIdx:$unreadCount';
if (_pendingChannelReadKey == nextKey &&
_channelReadTimer?.isActive == true) {
return;
}
_channelReadTimer?.cancel();
_pendingChannelReadKey = nextKey;
_channelReadTimer = Timer(_channelAutoReadDelay, () {
if (!mounted || !widget.isActive) {
return;
}
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return;
}
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (currentChannelIdx != channelIdx) {
return;
}
final latestProvider = context.read<MessagesProvider>();
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
return;
}
_markCurrentDestinationAsRead();
});
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1810,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider);
_syncChannelAutoReadTimer(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -1848,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,

View File

@@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget {
),
)
.toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
return _RouteSection(route: decodedRoute, path: resolvedPath);
},
);
}
@@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget {
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
const _RouteSection({required this.route, required this.path});
@override
Widget build(BuildContext context) {
@@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget {
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),

View File

@@ -14,6 +14,8 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
@@ -23,6 +25,7 @@ import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
@@ -72,6 +75,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
@@ -93,6 +103,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadFastLocationSettings();
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
}
@override
@@ -130,6 +142,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _loadNotificationPreferences() async {
final service = NotificationService();
await service.initialize();
if (!mounted) return;
setState(() {
_messageNotificationsEnabled = service.messageNotificationsEnabled;
_sarNotificationsEnabled = service.sarNotificationsEnabled;
_updateNotificationsEnabled = service.updateNotificationsEnabled;
_muteForegroundNotifications = service.muteForegroundNotifications;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
@@ -177,6 +201,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadMapPreferences() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
});
}
Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
@@ -980,6 +1019,72 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Notifications'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.chat_bubble_outline),
title: const Text('Message notifications'),
subtitle: const Text(
'Notify for incoming direct and channel messages',
),
value: _messageNotificationsEnabled,
onChanged: (value) async {
setState(() {
_messageNotificationsEnabled = value;
});
await NotificationService().setMessageNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.warning_amber_outlined),
title: const Text('SAR alerts'),
subtitle: const Text(
'Notify for incoming SAR markers such as found person or fire',
),
value: _sarNotificationsEnabled,
onChanged: (value) async {
setState(() {
_sarNotificationsEnabled = value;
});
await NotificationService().setSarNotificationsEnabled(value);
},
),
SwitchListTile(
secondary: const Icon(Icons.system_update),
title: const Text('Update notifications'),
subtitle: const Text(
'Notify when a newer app version is available',
),
value: _updateNotificationsEnabled,
onChanged: (value) async {
setState(() {
_updateNotificationsEnabled = value;
});
await NotificationService().setUpdateNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: const Text('Mute while app is open'),
subtitle: const Text(
'Do not show local notifications while the app is in the foreground',
),
value: _muteForegroundNotifications,
onChanged: (value) async {
setState(() {
_muteForegroundNotifications = value;
});
await NotificationService().setMuteForegroundNotifications(
value,
);
},
),
]),
_buildSectionHeader('Navigation'),
_buildSettingsCard([
Consumer<AppProvider>(
@@ -1025,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile(
leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'),
@@ -1154,6 +1246,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Map'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.explore),
title: const Text('Rotate map with heading'),
subtitle: const Text(
'Rotate the map based on your compass or movement heading',
),
value: _rotateMapWithHeading,
onChanged: (value) async {
setState(() {
_rotateMapWithHeading = value;
});
await _saveMapPreference('map_rotate_with_heading', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('Show map debug info'),
subtitle: const Text(
'Display extra map diagnostics and internal state overlays',
),
value: _showMapDebugInfo,
onChanged: (value) async {
setState(() {
_showMapDebugInfo = value;
});
await _saveMapPreference('map_show_debug_info', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.fullscreen),
title: const Text('Open map in fullscreen'),
subtitle: const Text(
'Start the map tab in fullscreen mode by default',
),
value: _openMapInFullscreen,
onChanged: (value) async {
setState(() {
_openMapInFullscreen = value;
});
await _saveMapPreference('map_fullscreen', value);
},
),
Consumer<DrawingProvider>(
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'),
subtitle: const Text('Display SAR markers on the main map'),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.timeline),
title: const Text('Show all contact trails'),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
value: mapProvider.showAllContactTrails,
onChanged: (value) async {
await mapProvider.toggleAllContactTrails();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Hide repeaters on map'),
subtitle: const Text(
'Hide repeater contacts from the main map view',
),
value: mapProvider.hideRepeatersOnMap,
onChanged: (value) async {
await mapProvider.setHideRepeatersOnMap(value);
},
),
),
]),
_buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) =>

View File

@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async {
@@ -126,6 +128,47 @@ class ContactStorageService {
}
}
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
}
}
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey);
if (jsonString == null || jsonString.isEmpty) {
return const [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.whereType<Map<String, dynamic>>().toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
return const [];
}
}
Future<void> clearPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey);
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -159,6 +202,7 @@ class ContactStorageService {
'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath),
'advName': contact.advName,
'nameOverride': contact.nameOverride,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
@@ -166,6 +210,15 @@ class ContactStorageService {
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
'advertHistory': contact.advertHistory
.map(
(point) => {
'lat': point.location.latitude,
'lon': point.location.longitude,
'tsMillis': point.timestamp.millisecondsSinceEpoch,
},
)
.toList(),
};
}
@@ -181,6 +234,7 @@ class ContactStorageService {
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String,
nameOverride: json['nameOverride'] as String?,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
@@ -188,6 +242,7 @@ class ContactStorageService {
telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null,
advertHistory: _advertHistoryFromJson(json['advertHistory']),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
@@ -240,6 +295,25 @@ class ContactStorageService {
}
}
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
if (json is! List) return [];
final result = <AdvertLocation>[];
for (final item in json) {
if (item is! Map<String, dynamic>) continue;
final lat = item['lat'];
final lon = item['lon'];
final tsMillis = item['tsMillis'];
if (lat is! num || lon is! num || tsMillis is! int) continue;
result.add(
AdvertLocation(
location: LatLng(lat.toDouble(), lon.toDouble()),
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
),
);
}
return result;
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
@@ -247,6 +321,8 @@ class ContactStorageService {
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
'matchPrefixes': group.matchPrefixes,
'isAutoGroup': group.isAutoGroup,
};
}
@@ -260,6 +336,10 @@ class ContactStorageService {
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
?.map((value) => value as String)
.toList(),
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');

View File

@@ -218,7 +218,8 @@ class LiveTrafficSummary {
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute = totalCount;
final packetsPerMinute =
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
return LiveTrafficSnapshot(
windowStart: effectiveStart,

View File

@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
@@ -269,12 +270,36 @@ class MessageStorageService {
await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
}
}
Future<Set<String>> loadRemovedSarMarkerIds() async {
try {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
return const <String>{};
}
}
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -304,6 +329,8 @@ class MessageStorageService {
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -312,6 +339,7 @@ class MessageStorageService {
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
@@ -440,6 +468,14 @@ class MessageStorageService {
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
pathBytes: json['pathBytes'] is List
? Uint8List.fromList(
(json['pathBytes'] as List<dynamic>)
.whereType<num>()
.map((b) => b.toInt())
.toList(),
)
: null,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
@@ -537,4 +573,12 @@ class MessageStorageService {
return null;
}
}
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
@@ -13,14 +14,26 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
static const String _prefSarEnabled = 'notifications_sar_enabled';
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
static const String _prefMuteForeground = 'notifications_mute_foreground';
bool _isInitialized = false;
bool _permissionGranted = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs
static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels
static const String _urgentChannelId = 'sar_urgent';
@@ -38,12 +51,31 @@ class NotificationService {
static const String _updateChannelDescription =
'Notifications for available app updates';
static const String _batteryChannelId = 'battery_alerts';
static const String _batteryChannelName = 'Battery Alerts';
static const String _batteryChannelDescription =
'Notifications when a device or contact battery is low';
static const String _discoveryChannelId = 'discovery_alerts';
static const String _discoveryChannelName = 'Discovery Alerts';
static const String _discoveryChannelDescription =
'Notifications when new contacts are discovered';
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
bool get muteForegroundNotifications => _muteForegroundNotifications;
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
/// Initialize notification service
Future<void> initialize() async {
if (_isInitialized) return;
try {
debugPrint('📬 [NotificationService] Initializing...');
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
_lifecycleState =
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
// Initialize timezone data
tz.initializeTimeZones();
@@ -72,8 +104,15 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse,
);
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions
await _requestPermissions();
await _loadPreferences();
// Create notification channels (Android)
await _createNotificationChannels();
@@ -132,6 +171,46 @@ class NotificationService {
}
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
}
Future<void> setMessageNotificationsEnabled(bool value) async {
_messageNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMessagesEnabled, value);
}
Future<void> setSarNotificationsEnabled(bool value) async {
_sarNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefSarEnabled, value);
}
Future<void> setUpdateNotificationsEnabled(bool value) async {
_updateNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefUpdatesEnabled, value);
}
Future<void> setMuteForegroundNotifications(bool value) async {
_muteForegroundNotifications = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMuteForeground, value);
}
void handleAppLifecycleStateChanged(AppLifecycleState state) {
_lifecycleState = state;
}
bool _shouldSuppressForegroundNotifications() {
return _muteForegroundNotifications && isAppInForeground;
}
/// Create notification channels for Android
Future<void> _createNotificationChannels() async {
try {
@@ -177,9 +256,31 @@ class NotificationService {
showBadge: true,
);
const batteryChannel = AndroidNotificationChannel(
_batteryChannelId,
_batteryChannelName,
description: _batteryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
const discoveryChannel = AndroidNotificationChannel(
_discoveryChannelId,
_discoveryChannelName,
description: _discoveryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
@@ -201,6 +302,12 @@ class NotificationService {
}
}
String? consumeLaunchPayload() {
final payload = _launchPayload;
_launchPayload = null;
return payload;
}
/// Show urgent notification for SAR marker
Future<void> showSarNotification({
required SarMarkerType type,
@@ -222,6 +329,16 @@ class NotificationService {
);
return;
}
if (!_sarNotificationsEnabled) {
debugPrint(' [NotificationService] SAR notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping SAR notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -394,6 +511,16 @@ class NotificationService {
);
return;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping message notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -547,6 +674,16 @@ class NotificationService {
);
return;
}
if (!_updateNotificationsEnabled) {
debugPrint(' [NotificationService] Update notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping update notification',
);
return;
}
try {
// Build notification title and body
@@ -617,4 +754,169 @@ class NotificationService {
);
}
}
Future<bool> showLowBatteryNotification({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return false;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return false;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return false;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping low battery notification',
);
return false;
}
final roundedPercent = batteryPercent.round().clamp(0, 100);
final title = isCurrentDevice
? 'Device battery low'
: 'Contact battery low';
final body = isCurrentDevice
? '$nodeName battery is at $roundedPercent%.'
: '$nodeName is at $roundedPercent% battery.';
final notificationId =
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_batteryChannelId,
_batteryChannelName,
channelDescription: _batteryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: '$roundedPercent%',
),
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'battery_alerts',
subtitle: '$roundedPercent%',
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'battery:$nodeId',
);
debugPrint(
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing low battery notification: $e',
);
return false;
}
}
Future<bool> showContactDiscoveredNotification({
required String contactKey,
String? contactName,
}) async {
if (!_isInitialized) return false;
if (!_permissionGranted) return false;
if (!_messageNotificationsEnabled) return false;
if (_shouldSuppressForegroundNotifications()) return false;
final shortKey = contactKey.length > 12
? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase();
final title = 'New contact discovered';
final resolvedName = contactName?.trim();
final body = resolvedName != null && resolvedName.isNotEmpty
? '$resolvedName is available in Discovery.'
: 'New contact $shortKey is available in Discovery.';
final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_discoveryChannelId,
_discoveryChannelName,
channelDescription: _discoveryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'discovery_alerts',
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
),
payload: 'discovery:$contactKey',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing discovery notification: $e',
);
return false;
}
}
}
class _LifecycleObserver with WidgetsBindingObserver {
_LifecycleObserver(this._service);
final NotificationService _service;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_service.handleAppLifecycleStateChanged(state);
}
}

View File

@@ -1,21 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/voice_message_parser.dart';
class VoiceCodecPreferences {
static const String _codecKey = 'voice_codec';
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
static Future<VoiceCodecKind> getCodec() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_codecKey);
return VoiceCodecKind.values.firstWhere(
(codec) => codec.name == raw,
orElse: () => defaultCodec,
);
}
static Future<void> setCodec(VoiceCodecKind codec) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_codecKey, codec.name);
}
}

View File

@@ -4,54 +4,98 @@ class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
final List<String> matchPrefixes;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
});
List<String>? matchPrefixes,
}) : matchPrefixes = matchPrefixes ?? const <String>[];
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactListItem {
final Contact? contact;
final InferredContactGroup? group;
const ContactListItem._({this.contact, this.group});
const ContactListItem.contact(Contact contact) : this._(contact: contact);
const ContactListItem.group(InferredContactGroup group)
: this._(group: group);
bool get isGroup => group != null;
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static String? inferredGroupLabelForContact(Contact contact) {
return _extractPrefix(contact.displayName)?.label;
}
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return false;
}
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
return groupLabel?.contains(normalizedQuery) ?? false;
}
static String? sharedInferredGroupLabel(List<Contact> contacts) {
String? sharedLabel;
for (final contact in contacts) {
final label = inferredGroupLabelForContact(contact);
if (label == null) {
return null;
}
if (sharedLabel == null) {
sharedLabel = label;
continue;
}
if (sharedLabel != label) {
return null;
}
}
return sharedLabel;
}
static String? sharedParentGroupLabel(List<String> labels) {
if (labels.isEmpty) {
return null;
}
var commonPrefix = labels.first;
for (final label in labels.skip(1)) {
final maxLength = commonPrefix.length < label.length
? commonPrefix.length
: label.length;
var matchLength = 0;
while (matchLength < maxLength &&
commonPrefix.codeUnitAt(matchLength) ==
label.codeUnitAt(matchLength)) {
matchLength++;
}
commonPrefix = commonPrefix.substring(0, matchLength);
if (commonPrefix.isEmpty) {
return null;
}
}
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
if (separatorIndex < 1) {
return null;
}
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
return parentLabel.length >= 3 ? parentLabel : null;
}
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<ContactListItem> buildItems(
static List<InferredContactGroup> inferGroups(
List<Contact> contacts, {
int minGroupSize = 4,
int? maxNamedGroups,
String? overflowGroupLabel,
}) {
final sortedContacts = sortByLastSeen(contacts);
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
}
static List<ContactListItem> buildItemsFromSorted(
List<Contact> sortedContacts, {
int minGroupSize = 4,
}) {
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
@@ -62,32 +106,46 @@ class ContactGrouping {
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final eligibleGroups = <String, InferredContactGroup>{};
final rankedGroups = <InferredContactGroup>[];
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
eligibleGroups[entry.key] = InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
rankedGroups.add(
InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
),
);
}
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
final emittedGroups = <String>{};
final items = <ContactListItem>[];
if (maxNamedGroups != null &&
overflowGroupLabel != null &&
rankedGroups.length > maxNamedGroups) {
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
final parentLabel = sharedParentGroupLabel([
for (final group in overflowGroups) ...group.matchPrefixes,
]);
if (parentLabel != null) {
final overflowContacts = overflowGroups
.expand((group) => group.contacts)
.toList();
return [
...retainedGroups,
InferredContactGroup(
key: parentLabel,
label: parentLabel,
contacts: sortByLastSeen(overflowContacts),
matchPrefixes: [parentLabel],
),
];
}
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
final group = prefix == null ? null : eligibleGroups[prefix.key];
if (group == null) {
items.add(ContactListItem.contact(contact));
continue;
}
if (emittedGroups.add(group.key)) {
items.add(ContactListItem.group(group));
}
return rankedGroups;
}
return items;
return rankedGroups;
}
static _GroupPrefix? _extractPrefix(String name) {

View File

@@ -9,14 +9,16 @@ extension MessageLocalization on Message {
/// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(id);
final messagesProvider = context.read<MessagesProvider>();
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context);
if (echoCount == 0) {
if (messagesProvider.hasChannelSendWarning(id)) {
return 'Broadcast may have failed';
}
return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) {
return latestMeta == null ? '1 node' : '1 node • $latestMeta';

View File

@@ -3,24 +3,42 @@ import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final MeshMapNode? node;
final List<MeshMapNode> candidates;
final int matchCount;
final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({
required this.node,
required this.candidates,
required this.matchCount,
required this.usedOnlineFallback,
this.selectedIndex = 0,
});
MeshMapNode? get node =>
candidates.isEmpty ? null : candidates[selectedIndex];
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
bool get canCycle => candidates.length > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
String? get cycleSummary =>
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
ResolvedTraceNode cycle() {
if (!canCycle) return this;
return ResolvedTraceNode(
candidates: candidates,
matchCount: matchCount,
usedOnlineFallback: usedOnlineFallback,
selectedIndex: (selectedIndex + 1) % candidates.length,
);
}
}
class TraceNodeResolver {
@@ -38,7 +56,7 @@ class TraceNodeResolver {
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
node: null,
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
@@ -49,7 +67,7 @@ class TraceNodeResolver {
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
node: null,
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
@@ -84,12 +102,93 @@ class TraceNodeResolver {
});
return ResolvedTraceNode(
node: pool.first,
candidates: List<MeshMapNode>.unmodifiable(pool),
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static List<ResolvedTraceNode> alignPathSelections({
required List<ResolvedTraceNode> nodes,
MeshMapNode? startNode,
MeshMapNode? endNode,
}) {
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
return nodes;
}
final candidateCosts = List.generate(
nodes.length,
(_) => <double>[],
growable: false,
);
final previousChoice = List.generate(
nodes.length,
(_) => <int>[],
growable: false,
);
for (var i = 0; i < nodes.length; i++) {
final currentCandidates = nodes[i].candidates;
candidateCosts[i] = List<double>.filled(
currentCandidates.length,
double.infinity,
);
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
for (var j = 0; j < currentCandidates.length; j++) {
final current = currentCandidates[j];
if (i == 0) {
candidateCosts[i][j] = startNode == null
? 0
: _distanceBetweenNodes(startNode, current);
continue;
}
final previousCandidates = nodes[i - 1].candidates;
for (var k = 0; k < previousCandidates.length; k++) {
final candidateCost =
candidateCosts[i - 1][k] +
_distanceBetweenNodes(previousCandidates[k], current);
if (candidateCost < candidateCosts[i][j]) {
candidateCosts[i][j] = candidateCost;
previousChoice[i][j] = k;
}
}
}
}
var bestLastIndex = 0;
var bestLastCost = double.infinity;
final lastCandidates = nodes.last.candidates;
for (var i = 0; i < lastCandidates.length; i++) {
final endCost = endNode == null
? 0
: _distanceBetweenNodes(lastCandidates[i], endNode);
final totalCost = candidateCosts.last[i] + endCost;
if (totalCost < bestLastCost) {
bestLastCost = totalCost;
bestLastIndex = i;
}
}
final selectedIndices = List<int>.filled(nodes.length, 0);
selectedIndices[nodes.length - 1] = bestLastIndex;
for (var i = nodes.length - 1; i > 0; i--) {
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
}
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
final resolved = nodes[index];
return ResolvedTraceNode(
candidates: resolved.candidates,
matchCount: resolved.matchCount,
usedOnlineFallback: resolved.usedOnlineFallback,
selectedIndex: selectedIndices[index],
);
}, growable: false);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
@@ -108,6 +207,14 @@ class TraceNodeResolver {
return double.maxFinite;
}
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
return _distance.as(
LengthUnit.Meter,
LatLng(a.latitude, a.longitude),
LatLng(b.latitude, b.longitude),
);
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;

View File

@@ -11,36 +11,26 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
enum VoiceCodecKind {
codec2(0, 'Codec2');
const VoiceCodecKind(this.id, this.label);
final int id;
final String label;
}
/// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
mode700c(0, '700C', 8000, 100, 1600),
mode1200(1, '1200', 8000, 150, 1040),
mode2400(2, '2400', 8000, 300, 520),
mode1300(3, '1300', 8000, 175, 880),
mode1400(4, '1400', 8000, 175, 880),
mode1600(5, '1600', 8000, 200, 800),
mode3200(6, '3200', 8000, 400, 400);
const VoicePacketMode(
this.id,
this.label,
this.codec,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id;
final String label;
final VoiceCodecKind codec;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
enum _RefreshEdge { top, bottom }
class BidirectionalRefresh extends StatefulWidget {
static const double defaultTriggerDistance = 90;
final Widget child;
final Future<void> Function() onRefresh;
final double triggerDistance;
const BidirectionalRefresh({
super.key,
required this.child,
required this.onRefresh,
this.triggerDistance = defaultTriggerDistance,
});
@override
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
}
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
double _topPullDistance = 0;
double _bottomPullDistance = 0;
bool _isRefreshing = false;
bool _handleScrollNotification(ScrollNotification notification) {
if (_isRefreshing) {
return false;
}
if (notification is ScrollStartNotification) {
_resetPullDistance();
return false;
}
if (notification is OverscrollNotification) {
final metrics = notification.metrics;
final isAtTop = metrics.extentBefore == 0;
final isAtBottom = metrics.extentAfter == 0;
if (!isAtTop && !isAtBottom) {
return false;
}
var shouldRefresh = false;
setState(() {
if (isAtTop) {
_topPullDistance += notification.overscroll.abs();
shouldRefresh = _topPullDistance >= widget.triggerDistance;
}
if (isAtBottom) {
_bottomPullDistance += notification.overscroll.abs();
shouldRefresh =
shouldRefresh || _bottomPullDistance >= widget.triggerDistance;
}
});
if (shouldRefresh) {
_runRefresh();
}
return false;
}
if (notification is ScrollEndNotification) {
_resetPullDistance();
}
return false;
}
Future<void> _runRefresh() async {
if (_isRefreshing) {
return;
}
setState(() {
_isRefreshing = true;
});
try {
await widget.onRefresh();
} finally {
if (mounted) {
setState(() {
_isRefreshing = false;
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
}
}
void _resetPullDistance() {
if (_topPullDistance == 0 && _bottomPullDistance == 0) {
return;
}
setState(() {
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
@override
Widget build(BuildContext context) {
final topProgress = (_topPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(
children: [
widget.child,
_RefreshIndicatorOverlay(
alignment: Alignment.topCenter,
progress: _isRefreshing ? 1 : topProgress,
visible: _isRefreshing || _topPullDistance > 0,
edge: _RefreshEdge.top,
),
_RefreshIndicatorOverlay(
alignment: Alignment.bottomCenter,
progress: _isRefreshing ? 1 : bottomProgress,
visible: _isRefreshing || _bottomPullDistance > 0,
edge: _RefreshEdge.bottom,
),
],
),
);
}
}
class _RefreshIndicatorOverlay extends StatelessWidget {
final Alignment alignment;
final double progress;
final bool visible;
final _RefreshEdge edge;
const _RefreshIndicatorOverlay({
required this.alignment,
required this.progress,
required this.visible,
required this.edge,
});
@override
Widget build(BuildContext context) {
if (!visible) {
return const SizedBox.shrink();
}
final indicator = RefreshProgressIndicator(value: progress);
final padding = edge == _RefreshEdge.top
? const EdgeInsets.only(top: 12)
: const EdgeInsets.only(bottom: 12);
return Positioned.fill(
child: IgnorePointer(
child: Align(
alignment: alignment,
child: Padding(
padding: padding,
child: SizedBox.square(dimension: 28, child: indicator),
),
),
),
);
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo;
class SignalMetric {
final String valueLabel;
final Color color;
final int activeBars;
const SignalMetric({
required this.valueLabel,
required this.color,
required this.activeBars,
});
static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
if (rxInfo == null) return null;
return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb);
}
static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) {
if (snrDb != null) {
return SignalMetric(
valueLabel: '${snrDb.toStringAsFixed(1)}dB',
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
activeBars: snrDb >= 10
? 3
: snrDb >= 0
? 2
: 1,
);
}
if (rssiDbm != null) {
return SignalMetric(
valueLabel: '$rssiDbm dBm',
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
activeBars: rssiDbm >= -80
? 3
: rssiDbm >= -95
? 2
: 1,
);
}
return null;
}
}
class CompactSignalIndicator extends StatelessWidget {
final SignalMetric metric;
final bool dense;
const CompactSignalIndicator({
super.key,
required this.metric,
this.dense = false,
});
@override
Widget build(BuildContext context) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
final barWidth = dense ? 4.0 : 5.0;
final baseHeight = dense ? 8.0 : 10.0;
final barStep = dense ? 6.0 : 8.0;
final barSpacing = dense ? 2.0 : 3.0;
final labelGap = dense ? 4.0 : 6.0;
final labelFontSize = dense ? 10.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) SizedBox(width: barSpacing),
Container(
width: barWidth,
height: baseHeight + (index * barStep),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
SizedBox(height: labelGap),
Text(
metric.valueLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
);
}
}

View File

@@ -1,20 +1,17 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding a new channel
class AddChannelDialog extends StatefulWidget {
/// Bottom sheet for adding a new channel
class AddChannelSheet extends StatefulWidget {
final Future<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({
super.key,
required this.onCreateChannel,
});
const AddChannelSheet({super.key, required this.onCreateChannel});
@override
State<AddChannelDialog> createState() => _AddChannelDialogState();
State<AddChannelSheet> createState() => _AddChannelSheetState();
}
class _AddChannelDialogState extends State<AddChannelDialog> {
class _AddChannelSheetState extends State<AddChannelSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _secretController = TextEditingController();
@@ -84,7 +81,7 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
try {
final channelName = _nameController.text.trim();
final isHashChannel = channelName.startsWith('#');
// For hash channels, pass empty secret (will be auto-generated)
// For private channels, use the provided secret
final secret = isHashChannel ? '' : _secretController.text;
@@ -109,152 +106,183 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Info banner explaining channel types
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
l10n.addChannel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: l10n.close,
),
],
),
),
const SizedBox(height: 16),
// Channel Name Field
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.channelName,
hintText: l10n.channelNameHint,
border: const OutlineInputBorder(),
prefixIcon: Icon(
isHashChannel ? Icons.tag : Icons.lock_outline,
color: isHashChannel ? Colors.blue : Colors.orange,
),
),
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
controller: _secretController,
decoration: InputDecoration(
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
// Help Text for private channels
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// Help Text for hash channels
if (isHashChannel) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.auto_awesome,
Icon(
Icons.info_outline,
size: 20,
color: Colors.blue,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hashChannelInfo,
l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.channelName,
hintText: l10n.channelNameHint,
border: const OutlineInputBorder(),
prefixIcon: Icon(
isHashChannel ? Icons.tag : Icons.lock_outline,
color: isHashChannel ? Colors.blue : Colors.orange,
),
),
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}),
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
if (!isHashChannel) ...[
const SizedBox(height: 16),
TextFormField(
controller: _secretController,
decoration: InputDecoration(
labelText: l10n.channelSecret,
hintText: l10n.channelSecretHint,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: !_isCreating,
maxLength: 32,
validator: _validateSecret,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleCreate(),
),
const SizedBox(height: 8),
Text(
l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (isHashChannel) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.auto_awesome,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.hashChannelInfo,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(l10n.createChannel),
),
),
],
),
],
],
),
),
),
),
actions: [
// Cancel Button
TextButton(
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
// Create Button
FilledButton(
onPressed: _isCreating ? null : _handleCreate,
child: _isCreating
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.createChannel),
),
],
);
}
}

View File

@@ -1,16 +1,17 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../models/path_history.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart';
import '../../services/route_hash_preferences.dart';
import '../../models/path_history.dart';
class ContactRouteDialogResult {
final ParsedContactRoute? route;
@@ -53,19 +54,11 @@ class ContactRouteDialog extends StatefulWidget {
required Contact contact,
required List<Contact> availableContacts,
}) {
return showModalBottomSheet<ContactRouteDialogResult>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ContactRouteDialog(
contact: contact,
availableContacts: availableContacts,
),
return Navigator.of(context).push<ContactRouteDialogResult>(
MaterialPageRoute(
builder: (context) => ContactRouteDialog(
contact: contact,
availableContacts: availableContacts,
),
),
);
@@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller;
late final TextEditingController _relaySearchController;
final PathHistoryService _pathHistoryService = PathHistoryService();
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute;
String? _errorText;
bool _showRoutingInfo = false;
bool _showManualEditor = false;
List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
_RouteEntryMode _entryMode = _RouteEntryMode.map;
@override
void initState() {
@@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_controller = TextEditingController(
text: widget.contact.routeCanonicalText,
);
_relaySearchController = TextEditingController();
_controller.addListener(_reparse);
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
? _RouteEntryMode.manual
: _RouteEntryMode.map;
_showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
_loadHashSizePreference();
_loadPathHistory();
_reparse();
@@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
@override
void dispose() {
_relaySearchController.dispose();
_controller
..removeListener(_reparse)
..dispose();
@@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.map;
_showManualEditor = false;
});
_reparse();
}
@@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.manual;
_showManualEditor = true;
});
_reparse();
}
@@ -416,30 +410,279 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}
Widget _buildPreviewSection() {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_parsedRoute == null ? 'Route preview' : _parsedRoute!.summary,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
_parsedRoute == null
? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
: '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_parsedRoute != null) ...[
const SizedBox(height: 10),
SelectableText(
_parsedRoute!.canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
],
),
);
}
Widget _buildSelectedHopSection() {
if (_selectedMapHops.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Text(
'No relays selected yet. Add repeaters below or let auto resolve build a route.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_parsedRoute == null
? 'Preview: enter or pick a route to validate it.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_parsedRoute != null) ...[
const SizedBox(height: 4),
SelectableText(
_parsedRoute!.canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
..._selectedMapHops.asMap().entries.map((entry) {
final index = entry.key;
final contact = entry.value;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(child: Text('${index + 1}')),
title: Text(contact.displayName),
subtitle: Text(
_tokenFor(contact, _selectedHashSize),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
trailing: IconButton(
tooltip: 'Remove relay',
onPressed: () => _toggleHop(contact),
icon: const Icon(Icons.close),
),
),
);
}),
],
);
}
Widget _buildBuilderTab(
BuildContext context, {
Widget _buildRelayPicker(List<Contact> routeCandidates) {
final query = _relaySearchController.text.trim().toLowerCase();
final filteredCandidates = routeCandidates.where((contact) {
if (query.isEmpty) return true;
return contact.displayName.toLowerCase().contains(query) ||
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _relaySearchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
labelText: 'Find relay',
hintText: 'Search by name or token',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
if (filteredCandidates.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor),
),
child: Text(
'No visible relays match this search.',
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
...filteredCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) => item.publicKeyHex == candidate.publicKeyHex,
);
final location = candidate.displayLocation;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
isSelected
? Icons.check_circle
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
title: Text(candidate.displayName),
subtitle: Text(
[
_tokenFor(candidate, _selectedHashSize),
if (location != null)
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
].join(''),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
trailing: TextButton(
onPressed: () => _toggleHop(candidate),
child: Text(isSelected ? 'Remove' : 'Add'),
),
),
);
}),
],
);
}
Widget _buildManualEditor() {
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
initiallyExpanded: _showManualEditor,
onExpansionChanged: (expanded) {
setState(() {
_showManualEditor = expanded;
});
},
title: const Text('Manual route edit'),
subtitle: const Text(
'Use this when you need to paste or tweak hop tokens directly.',
),
children: [
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
],
);
}
Widget _buildMapPreview({
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: SizedBox(
height: 220,
child: mapPoints.length < 2
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Map preview needs your advertised location, the contact location, and visible repeater locations.',
textAlign: TextAlign.center,
),
),
)
: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
padding: const EdgeInsets.all(32),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
if (routePoints.length >= 2)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: routePoints,
strokeWidth: 4,
color: colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: [
...routeCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) => item.publicKeyHex == candidate.publicKeyHex,
);
return flutter_map.Marker(
point: LatLng(
candidate.displayLocation!.latitude,
candidate.displayLocation!.longitude,
),
width: 64,
height: 70,
child: GestureDetector(
onTap: () => _toggleHop(candidate),
child: _RouteMarkerDot(
label: _tokenFor(candidate, _selectedHashSize),
color: isSelected
? colorScheme.primary
: Colors.blueGrey,
),
),
);
}),
],
),
],
),
),
);
}
Widget _buildBuilderTab({
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
@@ -447,174 +690,43 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_RouteEntryMode>(
segments: const [
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.map,
icon: Icon(Icons.map_outlined),
label: Text('Map'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _resolvePathAutomatically,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Auto resolve'),
),
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.manual,
icon: Icon(Icons.tune),
label: Text('Manual'),
OutlinedButton.icon(
onPressed: _selectedMapHops.isEmpty
? null
: () {
setState(() {
_selectedMapHops = const [];
_syncControllerFromSelectedHops();
});
},
icon: const Icon(Icons.clear_all),
label: const Text('Clear relays'),
),
],
selected: {_entryMode},
onSelectionChanged: (selection) {
setState(() {
_entryMode = selection.first;
});
},
),
const SizedBox(height: 16),
if (_entryMode == _RouteEntryMode.manual) ...[
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
] else ...[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _resolvePathAutomatically,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Resolve Path'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
'Tap repeaters on the map to build the path, then review the generated route below.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 260,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
),
child: mapPoints.length < 2
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
textAlign: TextAlign.center,
),
),
)
: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(32),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
if (routePoints.length >= 2)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: routePoints,
strokeWidth: 4,
color: Theme.of(context).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: [
...routeCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) =>
item.publicKeyHex ==
candidate.publicKeyHex,
);
return flutter_map.Marker(
point: LatLng(
candidate.displayLocation!.latitude,
candidate.displayLocation!.longitude,
),
width: 64,
height: 70,
child: GestureDetector(
onTap: () => _toggleHop(candidate),
child: _RouteMarkerDot(
label: _tokenFor(
candidate,
_selectedHashSize,
),
color: isSelected
? Theme.of(
context,
).colorScheme.primary
: Colors.blueGrey,
),
),
);
}),
],
),
],
),
),
),
),
const SizedBox(height: 12),
if (_selectedMapHops.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedMapHops.map((contact) {
return InputChip(
label: Text(contact.displayName),
onDeleted: () => _toggleHop(contact),
);
}).toList(),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
readOnly: true,
decoration: InputDecoration(
labelText: 'Generated route',
helperText: 'Switch to Manual if you want to edit the hop list.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
],
_buildPreviewSection(),
const SizedBox(height: 16),
_buildSelectedHopSection(),
const SizedBox(height: 16),
_buildRelayPicker(routeCandidates),
const SizedBox(height: 16),
_buildMapPreview(
routeCandidates: routeCandidates,
mapPoints: mapPoints,
routePoints: routePoints,
),
const SizedBox(height: 16),
_buildManualEditor(),
],
);
}
@@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return DefaultTabController(
length: 2,
child: FractionallySizedBox(
heightFactor: 0.85,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Choose how to build the route, or reuse one from history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
const TabBar(
tabs: [
Tab(text: 'Build'),
Tab(text: 'History'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Scaffold(
appBar: AppBar(
title: Text('Set Path for ${widget.contact.displayName}'),
bottom: const TabBar(
tabs: [
Tab(text: 'Build'),
Tab(text: 'History'),
],
),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Plan the route on its own screen, then save it once the preview looks right.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
children: [
ListView(
children: [
_buildBuilderTab(
context,
routeCandidates: routeCandidates,
mapPoints: mapPoints,
routePoints: routePoints,
@@ -772,44 +878,54 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
clearPathOnMaxRetry:
appProvider.clearPathOnMaxRetry,
),
const SizedBox(height: 24),
],
),
),
SingleChildScrollView(child: _buildHistoryTab()),
],
ListView(
children: [
_buildHistoryTab(),
const SizedBox(height: 24),
],
),
],
),
),
),
OverflowBar(
alignment: MainAxisAlignment.spaceBetween,
spacing: 8,
overflowSpacing: 8,
children: [
],
),
),
),
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: OverflowBar(
alignment: MainAxisAlignment.spaceBetween,
spacing: 8,
overflowSpacing: 8,
children: [
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(context).pop(
ContactRouteDialogResult.setWithFallback(
_parsedRoute!,
inferredFallbackLocation:
_buildSyntheticFallbackLocation(),
),
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
)
else
const SizedBox.shrink(),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(context).pop(
ContactRouteDialogResult.setWithFallback(
_parsedRoute!,
inferredFallbackLocation:
_buildSyntheticFallbackLocation(),
),
child: const Text('Set Route'),
),
],
),
],
),
child: const Text('Save Path'),
),
],
),
),
),
),
@@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}
}
enum _RouteEntryMode { map, manual }
class _RouteMarkerDot extends StatelessWidget {
final String label;
final Color color;

View File

@@ -22,6 +22,7 @@ import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget {
final Contact contact;
final String? groupLabel;
final bool compact;
final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance;
@@ -32,6 +33,7 @@ class ContactTile extends StatelessWidget {
super.key,
required this.contact,
this.groupLabel,
this.compact = false,
this.currentPosition,
this.calculateDistance,
this.formatDistance,
@@ -127,50 +129,46 @@ class ContactTile extends StatelessWidget {
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final Widget subtitleWidget = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (groupLabel case final label?)
_buildMetaPill(
context,
icon: Icons.folder_copy_outlined,
label: label,
final Widget subtitleWidget = compact
? _buildCompactSubtitle(context, distanceText)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (groupLabel case final label?)
_buildMetaPill(
context,
icon: Icons.folder_copy_outlined,
label: label,
),
],
),
_buildMetaPill(
context,
icon: Icons.key_outlined,
label: contact.publicKeyShort,
monospace: true,
),
],
),
if (location != null) ...[
const SizedBox(height: 2),
_buildLocationLine(
context,
latitude: location.latitude,
longitude: location.longitude,
distanceText: distanceText,
),
const SizedBox(height: 6),
Row(children: [_buildRoutePill(context, contact)]),
] else
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.grey),
),
),
],
);
if (location != null) ...[
const SizedBox(height: 2),
_buildLocationLine(
context,
latitude: location.latitude,
longitude: location.longitude,
distanceText: distanceText,
),
const SizedBox(height: 6),
Row(children: [_buildRoutePill(context, contact)]),
] else
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.grey),
),
),
],
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
@@ -294,6 +292,28 @@ class ContactTile extends StatelessWidget {
);
}
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation;
final compactPills = <Widget>[
if (groupLabel case final label?)
_buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label),
if (distanceText != null) _buildDistancePill(context, distanceText),
if (contact.routeHasPath && contact.routeHopCount > 0)
_buildRoutePill(context, contact),
if (location == null)
_buildMetaPill(
context,
icon: Icons.location_disabled_outlined,
label: AppLocalizations.of(context)!.noGpsData,
),
];
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(spacing: 6, runSpacing: 6, children: compactPills),
);
}
void _handlePrimaryTap(BuildContext context, Contact contact) {
_showContactActionSheet(context, contact);
}
@@ -305,7 +325,9 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room ||
contact.type == ContactType.channel;
final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room;
contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater;
final canAddToSensors =
contact.type == ContactType.chat ||
contact.type == ContactType.repeater;
@@ -318,104 +340,115 @@ class ContactTile extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.message_outlined),
title: Text(l10n.messages),
enabled: canMessage,
onTap: !canMessage
? null
: () async {
Navigator.pop(sheetContext);
await _openMessagesForContact(context, contact);
},
),
if (contact.displayLocation != null)
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showContactOnMap(context, contact);
},
),
if (contact.type == ContactType.room && !contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.login),
title: Text(
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLoginDialog(context, contact);
},
),
if (canAddToSensors)
ListTile(
leading: Icon(
isInSensors ? Icons.sensors : Icons.sensors_outlined,
),
title: Text(
isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
),
enabled: !isInSensors,
onTap: isInSensors
leading: const Icon(Icons.message_outlined),
title: Text(l10n.messages),
enabled: canMessage,
onTap: !canMessage
? null
: () async {
Navigator.pop(sheetContext);
await _addContactToSensors(context, contact);
await _openMessagesForContact(context, contact);
},
),
if (canSetPath)
ListTile(
leading: const Icon(Icons.alt_route),
title: Text(l10n.contactSetPath),
onTap: () {
Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact);
},
),
if (!contact.isChannel)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red),
if (contact.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showContactOnMap(context, contact);
},
),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (contact.type == ContactType.room && !contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.login),
title: Text(
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLoginDialog(context, contact);
},
),
if (canAddToSensors)
ListTile(
leading: Icon(
isInSensors ? Icons.sensors : Icons.sensors_outlined,
),
title: Text(
isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
),
enabled: !isInSensors,
onTap: isInSensors
? null
: () async {
Navigator.pop(sheetContext);
await _addContactToSensors(context, contact);
},
),
if (canSetPath)
ListTile(
leading: const Icon(Icons.alt_route),
title: Text(l10n.contactSetPath),
onTap: () {
Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact);
},
),
if (!contact.isChannel)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit name'),
onTap: () {
Navigator.pop(sheetContext);
_showNameOverrideDialog(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red),
),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
],
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
],
),
),
),
);
@@ -555,6 +588,40 @@ class ContactTile extends StatelessWidget {
}
}
Future<void> _showNameOverrideDialog(
BuildContext context,
Contact contact,
) async {
final l10n = AppLocalizations.of(context)!;
final result = await showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (dialogContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(dialogContext).viewInsets.bottom,
),
child: _ContactNameOverrideSheet(
initialValue: contact.nameOverride ?? '',
advertisedName: contact.advName,
cancelLabel: l10n.cancel,
saveLabel: l10n.save,
),
),
),
);
if (result == null || !context.mounted) {
return;
}
context.read<ContactsProvider>().setContactNameOverride(
contact.publicKeyHex,
result,
);
}
Future<void> _showSetRouteDialog(
BuildContext context,
Contact contact,
@@ -914,3 +981,105 @@ class ContactTile extends StatelessWidget {
);
}
}
class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue;
final String advertisedName;
final String cancelLabel;
final String saveLabel;
const _ContactNameOverrideSheet({
required this.initialValue,
required this.advertisedName,
required this.cancelLabel,
required this.saveLabel,
});
@override
State<_ContactNameOverrideSheet> createState() =>
_ContactNameOverrideSheetState();
}
class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
Navigator.of(context).pop(_controller.text);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Edit name',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Custom name',
hintText: widget.advertisedName,
helperText: 'Leave blank to use the advertised name.',
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submit,
child: Text(widget.saveLabel),
),
),
],
),
],
),
),
);
}
}

View File

@@ -23,6 +23,7 @@ class ContactTraceSheet extends StatefulWidget {
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
_ContactTraceResult? _traceOverride;
@override
void initState() {
@@ -89,7 +90,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
);
}
final trace = snapshot.data!;
final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null)
@@ -99,7 +100,9 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
.map((node) => LatLng(node.latitude, node.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>();
final relayNodes = trace.matchedRelayNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
@@ -128,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? 'Direct route to ${widget.contact.displayName}'
? 'No relay path saved for ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall,
),
@@ -196,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concreteNodes
.length -
1
? Colors.red
: Colors.blue),
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -233,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
@@ -249,13 +245,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -267,8 +268,11 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
),
),
const SizedBox(height: 12),
@@ -311,33 +315,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[];
if (trace.sender.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.sender));
}
entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
);
}),
);
if (trace.recipient.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient));
}
return entries;
}
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
return trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
);
}).toList();
}
String _prefixKeyLabel(String publicKey) =>
@@ -394,12 +383,17 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
),
)
.toList();
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
nodes: matchedRelayNodes,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
matchedRelayNodes: alignedRelayNodes,
);
}
@@ -493,6 +487,20 @@ class _ContactTraceResult {
required this.routeHashes,
required this.matchedRelayNodes,
});
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.relayNode:
final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _ContactTraceResult(
sender: sender,
recipient: recipient,
routeHashes: routeHashes,
matchedRelayNodes: updated,
);
}
}
}
class _RouteDisplayEntry {
@@ -500,26 +508,27 @@ class _RouteDisplayEntry {
final String label;
final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
required this.target,
});
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
);
}
}
enum _RouteEntryKind { relayNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index);
}

View File

@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
/// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget {
final Message message;
final int receivedCopies;
final VoidCallback? onTap;
final VoidCallback? onReply;
final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({
super.key,
required this.message,
this.receivedCopies = 1,
this.onTap,
this.onReply,
this.isHighlighted = false,
@@ -576,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -846,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails!.postTransmitDelayMs!,
),
),
if (widget.receivedCopies > 1)
_detailRow(
context,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
context,

View File

@@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
final Message? message;
final List<int>? packetPathOverride;
final String? descriptionOverride;
final String? noRelayMatchTextOverride;
const MessageTraceSheet({super.key, required this.message});
const MessageTraceSheet({super.key, required this.message})
: assert(message != null),
packetPathOverride = null,
descriptionOverride = null,
noRelayMatchTextOverride = null;
const MessageTraceSheet.packetPath({
super.key,
required List<int> packetPath,
this.descriptionOverride,
this.noRelayMatchTextOverride,
}) : message = null,
packetPathOverride = packetPath;
@override
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
@@ -27,6 +42,7 @@ class MessageTraceSheet extends StatefulWidget {
class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future;
_TraceResult? _traceOverride;
@override
void initState() {
@@ -39,20 +55,26 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final storedPath = messagesProvider
.getMessageReceptionDetails(widget.message.id)
?.pathBytes;
final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
List<int>? packetPath = widget.packetPathOverride;
String? senderPrefix;
String? recipientPrefix;
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
if (widget.message case final message?) {
final storedPath = messagesProvider
.getMessageReceptionDetails(message.id)
?.pathBytes;
packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: message,
);
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
recipientPrefix = message.recipientPublicKey != null
? _toPrefixHex(message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
}
final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
@@ -66,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
);
if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) {
return trace;
}
@@ -115,7 +139,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
);
}
final trace = snapshot.data!;
final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries
.where((entry) => entry.resolved.node != null)
@@ -153,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.mode == TraceMode.packetPath
? 'Route from packet path bytes'
: 'Route inferred from hop count (${widget.message.pathLen})',
widget.descriptionOverride ??
(trace.mode == TraceMode.packetPath
? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall,
),
),
@@ -222,14 +247,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concretePathNodes
.length -
1
? Colors.red
: Colors.blue),
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -259,29 +277,35 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
widget.noRelayMatchTextOverride ??
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -293,8 +317,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
),
),
const SizedBox(height: 12),
@@ -344,6 +371,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) {
if (widget.message == null) {
return concrete;
}
if (concrete.length <= 1) return const [];
return concrete.sublist(1);
}
@@ -353,22 +383,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender.node != null)
_RouteDisplayEntry.fromResolved(trace.sender),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
}
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
return trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: entry.value,
@@ -377,40 +393,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex,
matchSummary: entry.value.matchSummary,
target: _RouteEntryTarget.pathNode(entry.key),
);
}).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
}
final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromResolved(trace.sender),
...trace.matchedPathNodes
.where((entry) => entry.node != null)
.map(_RouteDisplayEntry.fromResolved),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
return trace.matchedPathNodes
.asMap()
.entries
.where((entry) => entry.value.node != null)
.map(
(entry) => _RouteDisplayEntry.fromResolved(
entry.value,
target: _RouteEntryTarget.pathNode(entry.key),
),
)
.toList();
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
@@ -486,7 +489,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
).reversed.toList();
);
final matched = _matchNodesFromPathHashes(
nodes: nodes,
localPublicKeys: localPublicKeys,
@@ -496,12 +499,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
final alignedMatched = TraceNodeResolver.alignPathSelections(
nodes: matched,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: hopHashes,
matchedPathNodes: matched,
matchedPathNodes: alignedMatched,
);
}
@@ -509,13 +517,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
nodes: nodes,
sender: senderNode.node,
recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen),
relayCount: math.max(0, widget.message!.pathLen),
);
final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode.node != null) senderNode,
...inferred.map(
(node) => ResolvedTraceNode(
node: node,
candidates: [node],
matchCount: 1,
usedOnlineFallback: false,
),
@@ -688,6 +696,21 @@ class _TraceResult {
required this.pathHashes,
required this.matchedPathNodes,
});
_TraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.pathNode:
final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _TraceResult(
mode: mode,
sender: sender,
recipient: recipient,
pathHashes: pathHashes,
matchedPathNodes: updated,
);
}
}
}
class _RouteDisplayEntry {
@@ -695,17 +718,22 @@ class _RouteDisplayEntry {
final String label;
final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
required this.target,
});
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
@@ -715,6 +743,19 @@ class _RouteDisplayEntry {
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
target: target,
);
}
}
enum _RouteEntryKind { pathNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index);
}

View File

@@ -3,7 +3,9 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../common/contact_avatar.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (mentionSuggestions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
child: _MentionSuggestionsCard(
suggestions: mentionSuggestions,
query: mentionQuery,
onSelected: onMentionSelected,
),
),
SafeArea(
top: false,
child: Padding(
@@ -72,7 +89,7 @@ class MessagesComposer extends StatelessWidget {
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -95,7 +112,7 @@ class MessagesComposer extends StatelessWidget {
),
],
),
const SizedBox(height: 8),
const SizedBox(height: 6),
ListenableBuilder(
listenable: Listenable.merge([
textController,
@@ -153,6 +170,87 @@ class MessagesComposer extends StatelessWidget {
}
}
class _MentionSuggestionsCard extends StatelessWidget {
final List<Contact> suggestions;
final String query;
final ValueChanged<Contact> onSelected;
const _MentionSuggestionsCard({
required this.suggestions,
required this.query,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final visibleCount = suggestions.length.clamp(1, 4);
final maxHeight = visibleCount * 54.0;
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
child: Container(
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final contact = suggestions[index];
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onSelected(contact),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 16),
const SizedBox(width: 10),
Expanded(
child: Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
@@ -165,8 +263,8 @@ class _ComposerActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
@@ -175,7 +273,7 @@ class _ComposerActionButton extends StatelessWidget {
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
@@ -203,7 +301,7 @@ class _DestinationSelector extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
@@ -212,17 +310,17 @@ class _DestinationSelector extends StatelessWidget {
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
@@ -230,7 +328,7 @@ class _DestinationSelector extends StatelessWidget {
),
Icon(
Icons.expand_more_rounded,
size: 20,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
@@ -261,7 +359,7 @@ class _MessageInput extends StatelessWidget {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
@@ -284,7 +382,7 @@ class _MessageInput extends StatelessWidget {
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: TextField(
controller: textController,
focusNode: focusNode,
@@ -375,8 +473,8 @@ class _SendButton extends StatelessWidget {
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
width: 42,
height: 42,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
@@ -410,7 +508,7 @@ class _SendButton extends StatelessWidget {
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
size: 20,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,

View File

@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../providers/messages_provider.dart';
import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final List<DisplayMessageEntry> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
@@ -30,14 +32,18 @@ class MessagesContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
const scrollPhysics = BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
return BidirectionalRefresh(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
physics: scrollPhysics,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
@@ -69,6 +75,7 @@ class MessagesContent extends StatelessWidget {
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: scrollPhysics,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
@@ -78,11 +85,13 @@ class MessagesContent extends StatelessWidget {
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final entry = messages[index];
final message = entry.message;
return MessageBubble(
key: ValueKey(message.id),
message: message,
receivedCopies: entry.occurrenceCount,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null

View File

@@ -12,6 +12,7 @@ class RecipientSelectorSheet extends StatefulWidget {
final Map<String, int> unreadCountsByPublicKey;
final String? currentDestinationType;
final String? currentRecipientPublicKey;
final bool showAllOption;
final Function(String type, Contact? recipient) onSelect;
const RecipientSelectorSheet({
@@ -23,6 +24,7 @@ class RecipientSelectorSheet extends StatefulWidget {
required this.unreadCountsByPublicKey,
this.currentDestinationType,
this.currentRecipientPublicKey,
this.showAllOption = true,
required this.onSelect,
});
@@ -61,6 +63,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
final filteredContacts = _filterContacts(widget.contacts);
final filteredRooms = _filterContacts(widget.rooms);
final filteredChannels = _filterContacts(widget.channels);
final showChannelsSection = widget.channels.isNotEmpty;
final showContactsSection = widget.contacts.isNotEmpty;
final showRoomsSection = widget.rooms.isNotEmpty;
final showAnyRecipients =
showChannelsSection || showContactsSection || showRoomsSection;
return Container(
constraints: BoxConstraints(
@@ -142,21 +149,23 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
child: ListView(
shrinkWrap: true,
children: [
_buildOptionTile(
context: context,
icon: Icons.all_inbox,
title: l10n.showAll,
subtitle: 'All messages',
unreadCount: widget.unreadCount,
isSelected: _isSelected('all', null),
onTap: () {
widget.onSelect('all', null);
Navigator.pop(context);
},
),
const Divider(),
if (widget.showAllOption) ...[
_buildOptionTile(
context: context,
icon: Icons.all_inbox,
title: l10n.showAll,
subtitle: 'All messages',
unreadCount: widget.unreadCount,
isSelected: _isSelected('all', null),
onTap: () {
widget.onSelect('all', null);
Navigator.pop(context);
},
),
if (showAnyRecipients) const Divider(),
],
// Channels section
if (widget.channels.isNotEmpty) ...[
if (showChannelsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -204,10 +213,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}),
],
const Divider(),
if (showChannelsSection &&
(showContactsSection || showRoomsSection))
const Divider(),
// Contacts section
if (widget.contacts.isNotEmpty) ...[
if (showContactsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -253,10 +264,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}),
],
const Divider(),
if (showContactsSection && showRoomsSection) const Divider(),
// Rooms section
if (widget.rooms.isNotEmpty) ...[
if (showRoomsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -302,9 +313,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
],
// Empty state
if (widget.contacts.isEmpty &&
widget.rooms.isEmpty &&
widget.channels.isEmpty) ...[
if (!showAnyRecipients) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(

View File

@@ -8,6 +8,10 @@ PODS:
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_avif_macos (0.0.1):
- FlutterMacOS
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
@@ -19,10 +23,6 @@ PODS:
- FlutterMacOS
- nsd_macos (0.0.1):
- FlutterMacOS
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- FlutterMacOS
- ObjectBox (= 4.4.1)
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
@@ -35,6 +35,9 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
@@ -43,23 +46,21 @@ DEPENDENCIES:
- codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
- objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
trunk:
- ObjectBox
EXTERNAL SOURCES:
audioplayers_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
@@ -69,6 +70,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_avif_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_local_notifications:
@@ -79,8 +84,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
nsd_macos:
:path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos
objectbox_flutter_libs:
:path: Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
@@ -91,28 +94,31 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: f51d18f6a4b5965c218843373b7dc5ed5e3a2008
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b
COCOAPODS: 1.16.2

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
ref: "813f5b3"
resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"

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
# 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.
version: 2026.0312.1+27
version: 2026.0313.2+29
environment:
sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client:
git:
url: https://github.com/dz0ny/meshcore_client.git
ref: main
ref: "813f5b3"
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter:

View File

@@ -0,0 +1,148 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
Uint8List _ownPublicKey() =>
Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List<int>.filled(26, 0)]);
void main() {
group('AppProvider channel info handling', () {
test('treats zeroed unnamed non-public channel as deleted', () {
expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue);
});
test('keeps unnamed non-public channel when secret is configured', () {
final secret = Uint8List.fromList([1, ...List<int>.filled(15, 0)]);
expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse);
expect(AppProvider.channelContactName(2, ''), 'Channel 2');
});
});
group('AppProvider self replay handling', () {
test('prefers self name over meshcore device name', () {
expect(
AppProvider.preferredSelfDisplayName(
deviceName: 'MeshCore-dz0ny (SI)',
selfName: 'dz0ny (SI)',
),
'dz0ny (SI)',
);
});
test('ignores direct self replay without hops', () {
final message = Message(
id: 'dm-self',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('ignores room self replay without hops', () {
final message = Message(
id: 'room-self',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello room',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('keeps direct self replay when it traversed hops', () {
final message = Message(
id: 'dm-self-routed',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isFalse,
);
});
test('ignores channel self replay by self name without hops', () {
final message = Message(
id: 'channel-self',
messageType: MessageType.channel,
senderName: 'dz0ny (SI)',
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello public',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('keeps channel self replay when it traversed hops', () {
final message = Message(
id: 'channel-self-routed',
messageType: MessageType.channel,
senderName: 'dz0ny (SI)',
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello public',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isFalse,
);
});
});
}

View File

@@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
void main() {
group('ConnectionProvider channel slot occupancy', () {
test('treats non-zero secret as configured', () {
expect(
ConnectionProvider.channelHasConfiguredSecret(Uint8List(16)),
isFalse,
);
expect(
ConnectionProvider.channelHasConfiguredSecret(
Uint8List.fromList([1, ...List<int>.filled(15, 0)]),
),
isTrue,
);
});
test('treats duplicate hashtag channels as conflicts', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: '#sar',
existingName: '#sar',
),
isTrue,
);
});
test('allows private channels with the same name to coexist', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: 'Ops',
existingName: 'Ops',
),
isFalse,
);
});
test('does not treat arbitrary empty channel info as deletion', () {
final provider = ConnectionProvider();
expect(
provider.shouldTreatChannelInfoAsDeleted(3, '', Uint8List(16)),
isFalse,
);
});
test('finds first missing custom channel slot', () {
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 4},
maxChannels: 6,
),
3,
);
});
test('returns null when all custom channel slots are occupied', () {
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 3, 4},
maxChannels: 5,
),
isNull,
);
});
test('private channels with same name do not block slot selection', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: 'RPT_admins',
existingName: 'RPT_admins',
),
isFalse,
);
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 3},
maxChannels: 6,
),
4,
);
});
});
}

View File

@@ -4,6 +4,7 @@ import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
@@ -427,6 +428,46 @@ void main() {
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
},
);
test('prefers a local name override and preserves it across refreshes', () {
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
final renamed = provider.findContactByKey(publicKey)!;
expect(renamed.nameOverride, 'Rescue One');
expect(renamed.displayName, 'Rescue One');
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Updated Advertised Name',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (46.0569 * 1e6).toInt(),
advLon: (14.5058 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final refreshed = provider.findContactByKey(publicKey)!;
expect(refreshed.nameOverride, 'Rescue One');
expect(refreshed.displayName, 'Rescue One');
expect(refreshed.advName, 'Updated Advertised Name');
});
test('persists a local name override across reloads', () async {
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
await Future<void>.delayed(Duration.zero);
final reloadedProvider = ContactsProvider();
await reloadedProvider.initializeEarly();
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
expect(reloaded.nameOverride, 'Rescue One');
expect(reloaded.displayName, 'Rescue One');
});
});
group('ContactsProvider route updates', () {
@@ -733,5 +774,45 @@ void main() {
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
});
test('replaces persisted auto groups for a section', () async {
await provider.replaceAutoGroupsForSection('repeaters', [
SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al',
sectionKey: 'repeaters',
label: 'AL-',
query: 'AL-',
createdAt: DateTime(2026, 3, 10, 12),
matchPrefixes: const ['AL-'],
isAutoGroup: true,
),
SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others',
sectionKey: 'repeaters',
label: 'Others',
query: 'Others',
createdAt: DateTime(2026, 3, 10, 12),
matchPrefixes: const ['CR-', 'DE-'],
isAutoGroup: true,
),
]);
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('repeaters'), hasLength(2));
expect(
restored.savedGroupsForSection('repeaters').first.matchPrefixes,
isNotEmpty,
);
expect(
restored.savedGroupsForSection('repeaters').first.isAutoGroup,
isTrue,
);
});
});
}
String publicKeyHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}

View File

@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -39,6 +40,46 @@ Message _buildDirectMessage(String id) {
);
}
Message _buildSentChannelMessage({
required String id,
required int senderTimestamp,
String text = 'broadcast',
int channelIdx = 0,
}) {
return Message(
id: id,
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: channelIdx,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: senderTimestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
);
}
Message _buildReceivedChannelReplay({
required String id,
required int senderTimestamp,
String text = 'broadcast',
int channelIdx = 0,
String senderName = 'Radio Alpha',
}) {
return Message(
id: id,
messageType: MessageType.channel,
channelIdx: channelIdx,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: senderTimestamp,
text: text,
senderName: senderName,
receivedAt: DateTime.now(),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -123,19 +164,9 @@ void main() {
test('channel messages are marked sent immediately', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
Message(
id: 'c1',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'broadcast',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
),
_buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000),
);
provider.markMessageSent('c1', 0, 0);
@@ -146,6 +177,259 @@ void main() {
);
});
test('channel warning appears when no echo arrives in time', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-warn', senderTimestamp: 1700000000),
);
provider.markMessageSent('c-warn', 0, 0);
expect(provider.hasChannelSendWarning('c-warn'), isFalse);
async.elapse(const Duration(seconds: 12));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn'), isTrue);
});
});
test('channel warning clears when echo arrives before timeout', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-warn-echo',
senderTimestamp: 1700000000,
),
);
provider.markMessageSent('c-warn-echo', 0, 0);
async.elapse(const Duration(seconds: 6));
provider.handleMessageEcho('c-warn-echo', 1, 4, -90);
async.elapse(const Duration(seconds: 6));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn-echo'), isFalse);
});
});
test('channel warning clears when replay is deduped into sent bubble', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-warn-replay',
senderTimestamp: 1700000100,
),
);
provider.markMessageSent('c-warn-replay', 0, 0);
async.elapse(const Duration(seconds: 12));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn-replay'), isTrue);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-warn-replay-incoming',
senderTimestamp: 1700000101,
senderName: 'dz0ny (SI)',
),
);
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
expect(provider.messages, hasLength(1));
});
});
test('channel replay is deduped for self sender within repeat window', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-echo', senderTimestamp: 1700000100),
);
provider.markMessageSent('c-echo', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-echo-incoming',
senderTimestamp: 1700000104,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-echo'));
expect(provider.messages.single.senderName, equals('dz0ny (SI)'));
});
test(
'channel replay is not deduped for different sender with same text',
() {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-no-echo',
senderTimestamp: 1700000200,
),
);
provider.markMessageSent('c-no-echo', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-no-echo-incoming',
senderTimestamp: 1700000201,
senderName: 'Radio Alpha',
),
);
expect(provider.messages, hasLength(2));
},
);
test('channel replay is not deduped outside repeat window', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-late', senderTimestamp: 1700000300),
);
provider.markMessageSent('c-late', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-late-incoming',
senderTimestamp: 1700000331,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(2));
});
test('channel replay can dedupe using lazily resolved self name', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400),
);
provider.markMessageSent('c-lazy', 0, 0);
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-lazy-incoming',
senderTimestamp: 1700000406,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-lazy'));
});
test('channel replay dedupes meshcore-prefixed self sender name', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-prefix', senderTimestamp: 1700000500),
);
provider.markMessageSent('c-prefix', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-prefix-incoming',
senderTimestamp: 1700000500,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-prefix'));
});
test('duplicate incoming message increments received copy count', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
provider.addMessage(
Message(
id: 'copy-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000600,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'copy-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000601,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(1));
expect(
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
equals(2),
);
});
test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
provider.addMessages([
Message(
id: 'dup-a',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000705,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
Message(
id: 'dup-b',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000700,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
]);
final display = provider.buildDisplayMessages(
provider.getRecentMessages(),
);
expect(display, hasLength(1));
expect(display.single.message.id, equals('dup-a'));
expect(display.single.occurrenceCount, equals(2));
});
test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) {
final provider = MessagesProvider();

View File

@@ -131,6 +131,108 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'dup-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'dup-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001999,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('dup-1'));
});
test('keeps separate incoming messages when text differs', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'unique-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'unique-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002999,
text: 'different payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(2));
});
test('removed SAR markers stay hidden after provider restore', () async {
final provider = MessagesProvider();
final message = Message(
id: 'sar-hidden',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
);
provider.addMessage(message);
expect(
provider.sarMarkers.map((marker) => marker.id),
contains(message.id),
);
await provider.removeSarMarker(message.id);
expect(provider.sarMarkers, isEmpty);
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
expect(
restoredProvider.messages.map((stored) => stored.id),
contains(message.id),
);
expect(
restoredProvider.sarMarkers.map((marker) => marker.id),
isNot(contains(message.id)),
);
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
});
test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope(

View File

@@ -0,0 +1,195 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildChannel({required String name, required int channelIndex}) {
final publicKey = Uint8List(32);
publicKey[0] = 0xFF;
publicKey[1] = channelIndex;
return Contact(
publicKey: publicKey,
type: ContactType.channel,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact buildRepeater({required int seed, required String name}) {
final publicKey = Uint8List(32);
publicKey[0] = seed;
publicKey[1] = seed + 1;
return Contact(
publicKey: publicKey,
type: ContactType.repeater,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46056000 + seed,
advLon: 14505000 + seed,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpContactsTab(
WidgetTester tester, {
List<Contact> contacts = const [],
List<SavedContactGroup> savedGroups = const [],
}) async {
final contactsProvider = ContactsProvider();
for (final contact in contacts) {
contactsProvider.addOrUpdateContact(contact);
}
if (savedGroups.isNotEmpty) {
await contactsProvider.replaceAutoGroupsForSection(
'repeaters',
savedGroups,
);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ContactsTab()),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('private channel activity card shows delete action in sheet', (
tester,
) async {
await pumpContactsTab(
tester,
contacts: [buildChannel(name: 'Ops', channelIndex: 3)],
);
expect(find.text('Ops'), findsOneWidget);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsWidgets);
expect(
find.text(
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
),
findsOneWidget,
);
});
testWidgets('repeaters show Others group when multiple groups exist', (
tester,
) async {
final repeaters = [
buildRepeater(seed: 10, name: 'AL-1'),
buildRepeater(seed: 11, name: 'AL-2'),
buildRepeater(seed: 12, name: 'AL-3'),
buildRepeater(seed: 13, name: 'AL-4'),
buildRepeater(seed: 20, name: 'BR-1'),
buildRepeater(seed: 21, name: 'BR-2'),
buildRepeater(seed: 22, name: 'BR-3'),
buildRepeater(seed: 23, name: 'BR-4'),
buildRepeater(seed: 30, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('BR-'), findsOneWidget);
expect(find.text('Others'), findsOneWidget);
expect(find.text('Lone Relay'), findsNothing);
});
testWidgets('repeaters stay flat when only one group exists', (tester) async {
final repeaters = [
buildRepeater(seed: 40, name: 'AL-1'),
buildRepeater(seed: 41, name: 'AL-2'),
buildRepeater(seed: 42, name: 'AL-3'),
buildRepeater(seed: 43, name: 'AL-4'),
buildRepeater(seed: 50, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget);
});
}

View File

@@ -66,7 +66,7 @@ void main() {
),
);
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('No packets for this filter'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
@@ -115,9 +115,15 @@ void main() {
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget);
expect(find.textContaining('RESP'), findsOneWidget);
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget);
expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
findsOneWidget,
);
expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget);
});
testWidgets('clear live view only resets transient screen state', (
@@ -151,7 +157,7 @@ void main() {
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('No packets for this filter'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
@@ -173,7 +179,7 @@ void main() {
refresh.value += 1;
await tester.pump();
expect(find.text('No live traffic yet'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget);
expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
});
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/screens/messages_tab.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:typed_data';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[0] = secondByte;
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('marks public channel as read after 5 seconds of viewing', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
messagesProvider.addMessage(
Message(
id: 'public-unread',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Unread on public channel',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pump();
expect(messagesProvider.unreadCount, 1);
await tester.pump(const Duration(seconds: 4));
expect(messagesProvider.unreadCount, 1);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(messagesProvider.unreadCount, 0);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
testWidgets('shows mention overlay above composer and inserts selection', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
]);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.byType(TextField).first);
await tester.enterText(find.byType(TextField).first, '@t');
await tester.pumpAndSettle();
expect(find.text('Tim'), findsOneWidget);
expect(find.text('Slane'), findsNothing);
await tester.tap(find.text('Tim'));
await tester.pumpAndSettle();
expect(find.text('@[Tim] '), findsOneWidget);
expect(find.text('Tim'), findsNothing);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
}

View File

@@ -122,6 +122,27 @@ void main() {
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
});
test('normalizes packet rate for windows longer than one minute', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs(
List.generate(
24,
(index) => _log(
timestamp: now.subtract(Duration(seconds: index * 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
),
now: now,
window: const Duration(minutes: 5),
);
expect(snapshot.totalCount, 24);
expect(snapshot.windowDuration, const Duration(minutes: 5));
expect(snapshot.packetsPerMinute, 5);
});
test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8));

View File

@@ -43,9 +43,11 @@ void main() {
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restoredMessages = await storage.loadMessages();
expect(restoredDetails.keys, contains(message.id));
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
},
);
@@ -76,6 +78,7 @@ void main() {
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
receivedCopies: 3,
),
},
);
@@ -93,9 +96,20 @@ void main() {
expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400);
expect(restored.receivedCopies, 3);
expect(
restored.packetLoggedAt,
DateTime.fromMillisecondsSinceEpoch(1700000100400),
);
});
test('persists removed SAR marker IDs', () async {
final storage = MessageStorageService();
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
final restored = await storage.loadRemovedSarMarkerIds();
expect(restored, equals({'sar-1', 'sar-2'}));
});
}

View File

@@ -26,12 +26,12 @@ void main() {
);
}
group('ContactGrouping.buildItems', () {
group('ContactGrouping.inferGroups', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
@@ -55,20 +55,20 @@ void main() {
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.first.group!.label, 'SI-');
expect(
items.first.group!.contacts.map((contact) => contact.displayName),
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
);
expect(items.last.contact!.displayName, 'OTHER');
expect(groups, hasLength(1));
expect(groups.first.label, 'SI-');
expect(groups.first.contacts.map((contact) => contact.displayName), [
'SI-1',
'SI-2',
'SI-3',
'SI-4',
]);
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
@@ -82,18 +82,12 @@ void main() {
),
]);
expect(items, hasLength(3));
expect(items.every((item) => !item.isGroup), isTrue);
expect(groups, isEmpty);
});
test('orders groups and ungrouped contacts by latest last seen', () {
test('orders groups by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
final groups = ContactGrouping.inferGroups([
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
@@ -110,11 +104,261 @@ void main() {
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'HR-1',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 7,
name: 'HR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 8,
name: 'HR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 9,
name: 'HR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.last.contact!.displayName, 'Lone');
expect(groups, hasLength(2));
expect(groups.first.label, 'SI-');
expect(groups.last.label, 'HR-');
});
test('collapses extra auto-groups into a shared parent prefix', () {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'HU-PE-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'HU-PE-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'HU-GA-1',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'HU-GA-2',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
buildContact(
seed: 13,
name: 'HU-PE-3',
lastSeen: now.subtract(const Duration(minutes: 12)),
),
buildContact(
seed: 14,
name: 'HU-PE-4',
lastSeen: now.subtract(const Duration(minutes: 13)),
),
buildContact(
seed: 15,
name: 'HU-GA-3',
lastSeen: now.subtract(const Duration(minutes: 14)),
),
buildContact(
seed: 16,
name: 'HU-GA-4',
lastSeen: now.subtract(const Duration(minutes: 15)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups, hasLength(3));
expect(groups[0].label, 'AL-');
expect(groups[1].label, 'BR-');
expect(groups[2].label, 'HU-');
expect(groups[2].contacts.map((contact) => contact.displayName), [
'HU-PE-1',
'HU-PE-2',
'HU-GA-1',
'HU-GA-2',
'HU-PE-3',
'HU-PE-4',
'HU-GA-3',
'HU-GA-4',
]);
expect(groups[2].matchPrefixes, ['HU-']);
});
test(
'does not create an Others bucket when overflow has no shared parent',
() {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'CR-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'CR-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'CR-3',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'CR-4',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
},
);
});
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
test('matches the inferred auto-group label for prefixed contacts', () {
final contact = buildContact(
seed: 99,
name: 'SI-1',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
isTrue,
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
isTrue,
);
});
test('returns false when a contact has no inferred auto-group label', () {
final contact = buildContact(
seed: 100,
name: 'Lone Contact',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
isFalse,
);
});
test('finds a shared inferred label for related repeater names', () {
final contacts = [
buildContact(
seed: 101,
name: 'HU-PE',
lastSeen: DateTime(2026, 3, 10, 12),
),
buildContact(
seed: 102,
name: 'HU-GA',
lastSeen: DateTime(2026, 3, 10, 11, 59),
),
];
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
});
test('finds a shared parent label for related subgroup prefixes', () {
expect(
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
'HU-',
);
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
});
});
}

View File

@@ -61,6 +61,92 @@ void main() {
expect(resolved.usedOnlineFallback, isTrue);
expect(resolved.matchCount, 1);
});
test('cycles through ambiguous local prefix matches', () {
final first = _node(
name: 'First Match',
publicKey: 'cc1100',
latitude: 46.08,
longitude: 14.52,
);
final second = _node(
name: 'Second Match',
publicKey: 'cc11ff',
latitude: 46.09,
longitude: 14.53,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [second, first],
localPublicKeys: {first.publicKey, second.publicKey},
prefixHex: 'cc11',
);
expect(resolved.matchCount, 2);
expect(resolved.canCycle, isTrue);
expect(resolved.node?.name, 'Second Match');
expect(resolved.cycle().node?.name, 'First Match');
expect(resolved.cycle().cycle().node?.name, 'Second Match');
});
test('aligns ambiguous hops to the closest continuous path', () {
final start = _node(
name: 'Start',
publicKey: 'start00',
latitude: 46.000,
longitude: 14.000,
);
final end = _node(
name: 'End',
publicKey: 'end000',
latitude: 46.300,
longitude: 14.300,
);
final hop1Near = _node(
name: 'Hop 1 Near',
publicKey: 'aa1100',
latitude: 46.100,
longitude: 14.100,
);
final hop1Far = _node(
name: 'Hop 1 Far',
publicKey: 'aa11ff',
latitude: 46.250,
longitude: 14.000,
);
final hop2Near = _node(
name: 'Hop 2 Near',
publicKey: 'bb2200',
latitude: 46.200,
longitude: 14.200,
);
final hop2Far = _node(
name: 'Hop 2 Far',
publicKey: 'bb22ff',
latitude: 46.050,
longitude: 14.280,
);
final aligned = TraceNodeResolver.alignPathSelections(
nodes: [
TraceNodeResolver.resolveBest(
nodes: [hop1Far, hop1Near],
localPublicKeys: {hop1Near.publicKey, hop1Far.publicKey},
prefixHex: 'aa11',
),
TraceNodeResolver.resolveBest(
nodes: [hop2Far, hop2Near],
localPublicKeys: {hop2Near.publicKey, hop2Far.publicKey},
prefixHex: 'bb22',
),
],
startNode: start,
endNode: end,
);
expect(aligned[0].node?.name, 'Hop 1 Near');
expect(aligned[1].node?.name, 'Hop 2 Near');
});
}
MeshMapNode _node({

View File

@@ -4,7 +4,7 @@ import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart';
void main() {
Future<void> pumpDialog(
Future<void> pumpSheet(
WidgetTester tester, {
required Future<void> Function(String name, String secret) onCreateChannel,
}) async {
@@ -15,7 +15,7 @@ void main() {
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: AddChannelDialog(onCreateChannel: onCreateChannel),
child: AddChannelSheet(onCreateChannel: onCreateChannel),
),
),
),
@@ -27,7 +27,7 @@ void main() {
String? submittedName;
String? submittedSecret;
await pumpDialog(
await pumpSheet(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;
@@ -47,7 +47,7 @@ void main() {
testWidgets('uses done action for hash channels', (tester) async {
String? submittedName;
await pumpDialog(
await pumpSheet(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/widgets/common/bidirectional_refresh.dart';
void main() {
Future<BuildContext> pumpRefreshHarness(
WidgetTester tester, {
required Future<void> Function() onRefresh,
}) {
late BuildContext childContext;
return tester
.pumpWidget(
MaterialApp(
home: Scaffold(
body: BidirectionalRefresh(
onRefresh: onRefresh,
child: Builder(
builder: (context) {
childContext = context;
return const SizedBox.expand();
},
),
),
),
),
)
.then((_) => childContext);
}
OverscrollNotification topOverscroll(BuildContext context) {
return OverscrollNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 500,
pixels: 0,
viewportDimension: 300,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: context,
overscroll: -120,
);
}
OverscrollNotification bottomOverscroll(BuildContext context) {
return OverscrollNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 500,
pixels: 500,
viewportDimension: 300,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: context,
overscroll: 120,
);
}
testWidgets('refresh triggers from top overscroll', (tester) async {
var refreshCount = 0;
final childContext = await pumpRefreshHarness(
tester,
onRefresh: () async {
refreshCount++;
},
);
topOverscroll(childContext).dispatch(childContext);
await tester.pump();
expect(refreshCount, 1);
});
testWidgets('refresh triggers from bottom overscroll', (tester) async {
var refreshCount = 0;
final childContext = await pumpRefreshHarness(
tester,
onRefresh: () async {
refreshCount++;
},
);
bottomOverscroll(childContext).dispatch(childContext);
await tester.pump();
expect(refreshCount, 1);
});
}

View File

@@ -82,4 +82,26 @@ void main() {
expect(find.text('Trace'), findsNothing);
});
testWidgets('shows overridden contact name as primary label', (tester) async {
await pumpTile(
tester,
buildContact(
name: 'John Smith',
type: ContactType.chat,
).copyWith(nameOverride: 'Rescue One'),
);
expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing);
});
testWidgets('hides public key in contact tile', (tester) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await pumpTile(tester, contact);
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
});
}

View File

@@ -71,4 +71,31 @@ void main() {
expect(find.text('7'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
testWidgets('can hide show all option for contact-only flows', (
tester,
) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: [contact],
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: const {},
showAllOption: false,
onSelect: (selectedRecipient, draftMessage) {},
),
),
),
);
expect(find.text('Show all'), findsNothing);
expect(find.text('John Smith'), findsOneWidget);
});
}