Compare commits

..

50 Commits

Author SHA1 Message Date
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34: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
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
77 changed files with 15355 additions and 1973 deletions

1
.gitignore vendored
View File

@@ -80,6 +80,7 @@ create_feature_graphic.py
# Tool caches and local state # Tool caches and local state
.cachebro/ .cachebro/
.osgrep/ .osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config) # Claude Code local settings (permissions, personal config)
.claude/settings.local.json .claude/settings.local.json

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 = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 111; CURRENT_PROJECT_VERSION = 116;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.924905"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="113.366384"> <testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="755.542625"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
</testcase> </testcase>

View File

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

View File

@@ -0,0 +1,39 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
final List<String>? matchPrefixes;
final bool isAutoGroup;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
this.matchPrefixes,
this.isAutoGroup = false,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
List<String>? matchPrefixes,
bool? isAutoGroup,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
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? advLat;
final int? advLon; final int? advLon;
final bool? manualAddContacts; final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat, this.advLat,
this.advLon, this.advLon,
this.manualAddContacts, this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat, int? advLat,
int? advLon, int? advLon,
bool? manualAddContacts, bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat, advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon, advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts, 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, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs; final int? senderToReceiptMs;
final int? estimatedTransmitMs; final int? estimatedTransmitMs;
final int? postTransmitDelayMs; final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({ const MessageReceptionDetails({
required this.capturedAt, required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs, this.senderToReceiptMs,
this.estimatedTransmitMs, this.estimatedTransmitMs,
this.postTransmitDelayMs, this.postTransmitDelayMs,
this.receivedCopies = 1,
}); });
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs, 'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs, 'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs, '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) { static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis']; final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) { if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?, senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?, estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?, postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
); );
} }
} }

View File

@@ -1,7 +1,10 @@
enum PathRecordSource { learned, observed }
class PathRecord { class PathRecord {
final List<int> pathBytes; final List<int> pathBytes;
final int hopCount; final int hopCount;
final int hashSize; final int hashSize;
final PathRecordSource source;
final int successCount; final int successCount;
final int failureCount; final int failureCount;
final int lastRoundTripTimeMs; final int lastRoundTripTimeMs;
@@ -11,6 +14,7 @@ class PathRecord {
required this.pathBytes, required this.pathBytes,
required this.hopCount, required this.hopCount,
required this.hashSize, required this.hashSize,
required this.source,
required this.successCount, required this.successCount,
required this.failureCount, required this.failureCount,
required this.lastRoundTripTimeMs, required this.lastRoundTripTimeMs,
@@ -27,6 +31,7 @@ class PathRecord {
List<int>? pathBytes, List<int>? pathBytes,
int? hopCount, int? hopCount,
int? hashSize, int? hashSize,
PathRecordSource? source,
int? successCount, int? successCount,
int? failureCount, int? failureCount,
int? lastRoundTripTimeMs, int? lastRoundTripTimeMs,
@@ -36,6 +41,7 @@ class PathRecord {
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount, hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize, hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount, successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount, failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
@@ -48,6 +54,7 @@ class PathRecord {
'path_bytes': pathBytes, 'path_bytes': pathBytes,
'hop_count': hopCount, 'hop_count': hopCount,
'hash_size': hashSize, 'hash_size': hashSize,
'source': source.name,
'success_count': successCount, 'success_count': successCount,
'failure_count': failureCount, 'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs, 'last_round_trip_time_ms': lastRoundTripTimeMs,
@@ -62,6 +69,10 @@ class PathRecord {
.toList(), .toList(),
hopCount: json['hop_count'] as int? ?? 0, hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1, hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0, successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
@@ -163,6 +174,10 @@ class ContactPathHistory {
}; };
} }
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson( factory ContactPathHistory.fromJson(
String contactPublicKeyHex, String contactPublicKeyHex,
Map<String, dynamic> json, Map<String, dynamic> json,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
/// Message delivery tracking helper /// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup /// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {}; 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 /// Track a pending message ID before sending
/// ///
/// This is called BEFORE sending the message. When RESP_CODE_SENT /// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId); _messageIdToAckTag.remove(messageId);
} }
_ackTagTimestamps.remove(ackCode); _ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
} }
/// Remove ACK tag mapping by message ID /// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) { if (ackTag != null) {
_ackTagToMessageId.remove(ackTag); _ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag); _ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
} }
_pendingMessageIds.remove(messageId); _pendingMessageIds.remove(messageId);
final emptyKeys = <String>[]; final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear(); _ackTagToMessageId.clear();
_messageIdToAckTag.clear(); _messageIdToAckTag.clear();
_ackTagTimestamps.clear(); _ackTagTimestamps.clear();
_notifySlotFreed();
} }
/// Get count of pending ACK tags /// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8) /// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7; 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) /// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp { DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null; if (_ackTagTimestamps.isEmpty) return null;

View File

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

View File

@@ -18,13 +18,20 @@ import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart'; import 'helpers/message_retry_manager.dart';
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
final Set<String> _removedSarMarkerIds = <String>{};
final MessageStorageService _storageService = MessageStorageService(); final MessageStorageService _storageService = MessageStorageService();
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {}; final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
@@ -37,6 +44,8 @@ class MessagesProvider with ChangeNotifier {
// Track timeout timers for pending messages // Track timeout timers for pending messages
// Key: message ID (not ACK tag, since multiple messages can share same ACK) // Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {}; 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. // Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {}; final Map<int, DateTime> _completedAckHistory = {};
@@ -86,6 +95,7 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})? Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback; onDirectPathFailedCallback;
void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({ Future<bool> Function({
required String messageId, required String messageId,
required Contact contact, required Contact contact,
@@ -124,6 +134,7 @@ class MessagesProvider with ChangeNotifier {
_messages.where((m) => m.isSystemMessage).toList(); _messages.where((m) => m.isSystemMessage).toList();
List<SarMarker> get sarMarkers => _sarMarkers.values.toList(); List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
Set<String> get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds);
List<SarMarker> get foundPersonMarkers => List<SarMarker> get foundPersonMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList(); sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
@@ -153,6 +164,9 @@ class MessagesProvider with ChangeNotifier {
MessageRouteMetadata? getMessageRouteMetadata(String messageId) => MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
_messageRouteMetadata[messageId]; _messageRouteMetadata[messageId];
bool hasChannelSendWarning(String messageId) =>
_channelEchoWarningMessageIds.contains(messageId);
void updateMessageRouteSelection( void updateMessageRouteSelection(
String messageId, String messageId,
PathSelection selection, { PathSelection selection, {
@@ -165,8 +179,12 @@ class MessagesProvider with ChangeNotifier {
final index = _messages.indexWhere((message) => message.id == messageId); final index = _messages.indexWhere((message) => message.id == messageId);
if (index != -1) { if (index != -1) {
final nextPathLen = selection.hopCount > 0
? selection.hopCount
: _messages[index].pathLen;
_messages[index] = _messages[index].copyWith( _messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood, usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen,
); );
} }
@@ -239,6 +257,17 @@ class MessagesProvider with ChangeNotifier {
) )
.length; .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) { bool _isMessageForDestination(Message message, Contact contact) {
if (message.isSystemMessage) return false; if (message.isSystemMessage) return false;
@@ -293,6 +322,8 @@ class MessagesProvider with ChangeNotifier {
.loadMessageTransferDetails(); .loadMessageTransferDetails();
final storedRouteMetadata = await _storageService final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata(); .loadMessageRouteMetadata();
final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds();
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
@@ -305,6 +336,9 @@ class MessagesProvider with ChangeNotifier {
_messageRouteMetadata _messageRouteMetadata
..clear() ..clear()
..addAll(storedRouteMetadata); ..addAll(storedRouteMetadata);
_removedSarMarkerIds
..clear()
..addAll(storedRemovedSarMarkerIds);
// Add stored messages with enhancement to ensure SAR detection // Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) { for (final message in storedMessages) {
@@ -352,7 +386,7 @@ class MessagesProvider with ChangeNotifier {
// Extract SAR markers // Extract SAR markers
if (enhancedMessage.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
} }
@@ -493,6 +527,7 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// For channel messages with sender name, try to link with contact // For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage; Message finalMessage = enhancedMessage;
@@ -518,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 // Debug: Check if message is SAR
if (message.text.startsWith('S:')) { if (message.text.startsWith('S:')) {
debugPrint( debugPrint(
@@ -532,29 +576,40 @@ class MessagesProvider with ChangeNotifier {
// - Mesh network retransmissions // - Mesh network retransmissions
// - Multiple paths in the network // - Multiple paths in the network
// - Syncing messages from device queue // - Syncing messages from device queue
if (_isDuplicate(finalMessage)) { final duplicateIndex = _findDuplicateMessageIndex(finalMessage);
if (duplicateIndex != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}', '⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
); );
debugPrint( debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
); );
final existingIndex = _messages.indexWhere( final existingId = _messages[duplicateIndex].id;
(existing) => if (finalMessage.isChannelMessage &&
existing.messageType == finalMessage.messageType && !finalMessage.isSentMessage &&
existing.senderTimestamp == finalMessage.senderTimestamp && _messages[duplicateIndex].isSentMessage) {
existing.text == finalMessage.text, _clearChannelSendWarning(existingId);
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
} }
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 return; // Skip duplicate
} }
@@ -569,7 +624,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) { if (finalMessage.isSarMarker) {
final marker = finalMessage.toSarMarker(); final marker = finalMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
// Trigger urgent notification for received SAR messages (not sent by user) // Trigger urgent notification for received SAR messages (not sent by user)
@@ -593,50 +648,101 @@ class MessagesProvider with ChangeNotifier {
/// Messages are considered duplicates if they have: /// Messages are considered duplicates if they have:
/// 1. Same sender public key prefix (for contact messages) /// 1. Same sender public key prefix (for contact messages)
/// 2. Same channel index (for channel messages) /// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp /// 3. Same text content
/// 4. Same text content
/// ///
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates /// Note: Sent messages (isSentMessage=true) are NEVER duplicates
/// because they can be retried with different message IDs /// 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 // Sent messages (our own messages) should never be considered duplicates
// They can be retried multiple times with different IDs // They can be retried multiple times with different IDs
if (message.isSentMessage) { 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 false;
} }
return _messages.any((existing) { if (message.isContactMessage) {
// Check message type matches return existing.senderKeyShort == message.senderKeyShort;
if (existing.messageType != message.messageType) { }
if (message.isChannelMessage) {
if (existing.channelIdx != message.channelIdx) {
return false; return false;
} }
// Check sender matches final withinChannelRepeatWindow =
if (message.isContactMessage) { (existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
// For contact messages, compare sender public key prefix
if (existing.senderKeyShort != message.senderKeyShort) { 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; return false;
} }
} else if (message.isChannelMessage) {
// For channel messages, compare channel index // Mirror meshcore-open behavior for self-authored channel messages:
if (existing.channelIdx != message.channelIdx) { // zero-hop self replays are dropped earlier in AppProvider, while
return false; // 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) return false;
if (existing.senderTimestamp != message.senderTimestamp) { }
return false;
}
// Check text content matches // System messages and other types: never deduplicate by scope alone.
if (existing.text != message.text) { return false;
return false;
}
// All criteria match - this is a duplicate
return true;
});
} }
/// Add multiple messages /// Add multiple messages
@@ -646,10 +752,42 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) { for (final message in messages) {
// Always enhance message with SAR parser to detect SAR markers // 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 // 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++; duplicateCount++;
continue; // Skip duplicate continue; // Skip duplicate
} }
@@ -659,7 +797,7 @@ class MessagesProvider with ChangeNotifier {
if (enhancedMessage.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
} }
@@ -675,6 +813,80 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); 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 /// Trigger urgent notification for SAR marker
Future<void> _triggerSarNotification( Future<void> _triggerSarNotification(
Message message, Message message,
@@ -820,18 +1032,31 @@ class MessagesProvider with ChangeNotifier {
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB'; 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 { Future<void> _persistMessages() async {
_persistRequested = true;
if (_isPersisting) {
return; // A write is in flight; it will pick up our changes.
}
_isPersisting = true;
try { try {
await _storageService.saveMessages( while (_persistRequested) {
_messages, _persistRequested = false;
messageContactLocations: _messageContactLocations, await _storageService.saveMessages(
messageReceptionDetails: _messageReceptionDetails, _messages,
messageTransferDetails: _messageTransferDetails, messageContactLocations: _messageContactLocations,
messageRouteMetadata: _messageRouteMetadata, messageReceptionDetails: _messageReceptionDetails,
); messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
}
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} finally {
_isPersisting = false;
} }
} }
@@ -854,6 +1079,55 @@ class MessagesProvider with ChangeNotifier {
.toList(); .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) /// Get recent messages (last N messages)
List<Message> getRecentMessages({int count = 50}) { List<Message> getRecentMessages({int count = 50}) {
final sorted = List<Message>.from(_messages) final sorted = List<Message>.from(_messages)
@@ -861,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
return sorted.take(count).toList(); 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 /// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) { List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration); final cutoff = DateTime.now().subtract(duration);
@@ -881,17 +1184,35 @@ class MessagesProvider with ChangeNotifier {
return _sarMarkers[id]; 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) /// Get recent SAR markers (within last hour)
List<SarMarker> getRecentSarMarkers() { List<SarMarker> getRecentSarMarkers() {
return sarMarkers.where((m) => m.isRecent).toList(); return sarMarkers.where((m) => m.isRecent).toList();
} }
/// Remove a SAR marker /// Remove a SAR marker
void removeSarMarker(String id) { Future<void> removeSarMarker(String id) async {
_sarMarkers.remove(id); _sarMarkers.remove(id);
_removedSarMarkerIds.add(id);
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners(); 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 /// Mark all messages as read
void markAllAsRead() { void markAllAsRead() {
bool hasChanges = false; bool hasChanges = false;
@@ -979,6 +1300,7 @@ class MessagesProvider with ChangeNotifier {
final marker = message.toSarMarker(); final marker = message.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers.remove(marker.id); _sarMarkers.remove(marker.id);
_removedSarMarkerIds.add(marker.id);
} }
} }
@@ -988,6 +1310,7 @@ class MessagesProvider with ChangeNotifier {
// Cancel timeout timer if it exists // Cancel timeout timer if it exists
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id); _timeoutTimers.remove(message.id);
_clearChannelSendWarning(message.id);
if (message.expectedAckTag != null) { if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
@@ -1001,6 +1324,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
} }
@@ -1023,31 +1347,55 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages /// Clear all messages
void clearMessages() { void clearMessages() {
_cancelAllTimers();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear(); _messageTransferDetails.clear();
_messageRouteMetadata.clear(); _messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
/// Clear all SAR markers /// Clear all SAR markers
void clearSarMarkers() { Future<void> clearSarMarkers() async {
_removedSarMarkerIds.addAll(_sarMarkers.keys);
_sarMarkers.clear(); _sarMarkers.clear();
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners(); notifyListeners();
} }
/// Clear all data /// Clear all data
void clearAll() { void clearAll() {
_cancelAllTimers();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear(); _messageTransferDetails.clear();
_messageRouteMetadata.clear(); _messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
@@ -1206,9 +1554,10 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates (shouldn't happen for sent messages, but be safe) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_isDuplicate(enhancedMessage)) { if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
); );
@@ -1233,7 +1582,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) { if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker(); final marker = sendingMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
debugPrint(' ✅ SAR Marker created:'); debugPrint(' ✅ SAR Marker created:');
debugPrint(' marker.id: ${marker.id}'); debugPrint(' marker.id: ${marker.id}');
debugPrint(' marker.notes: "${marker.notes}"'); debugPrint(' marker.notes: "${marker.notes}"');
@@ -1452,6 +1801,9 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
' Channel message (no ACK tracking) - marked as sent immediately', ' Channel message (no ACK tracking) - marked as sent immediately',
); );
if (message.isChannelMessage) {
_scheduleChannelEchoWarning(messageId);
}
} }
debugPrint(' Calling notifyListeners() to update UI with "sent" status'); debugPrint(' Calling notifyListeners() to update UI with "sent" status');
@@ -1500,6 +1852,7 @@ class MessagesProvider with ChangeNotifier {
lastEchoAt: DateTime.now(), lastEchoAt: DateTime.now(),
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
_clearChannelSendWarning(messageId);
debugPrint(' Updated echo count to: $echoCount'); debugPrint(' Updated echo count to: $echoCount');
_persistMessages(); _persistMessages();
@@ -1721,7 +2074,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Message index in list: $index'); debugPrint(' Message index in list: $index');
if (index != -1) { if (index != -1) {
final updatedMessage = message.copyWith( final updatedMessage = _messages[index].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered, deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(), deliveredAt: DateTime.now(),
@@ -1875,6 +2228,7 @@ class MessagesProvider with ChangeNotifier {
} }
final message = _messages[index]; final message = _messages[index];
_clearChannelSendWarning(messageId);
final contact = _messageContactMap[messageId]; final contact = _messageContactMap[messageId];
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed'); debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
@@ -1914,6 +2268,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(), lastRetryAt: DateTime.now(),
); );
_clearChannelSendWarning(messageId);
// Cancel old timeout timer // Cancel old timeout timer
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
@@ -1979,6 +2334,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(), lastRetryAt: DateTime.now(),
); );
_clearChannelSendWarning(messageId);
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id); _timeoutTimers.remove(message.id);
@@ -2069,6 +2425,7 @@ class MessagesProvider with ChangeNotifier {
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId);
_messages[index] = Message( _messages[index] = Message(
id: message.id, id: message.id,
@@ -2076,6 +2433,7 @@ class MessagesProvider with ChangeNotifier {
senderPublicKeyPrefix: message.senderPublicKeyPrefix, senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx, channelIdx: message.channelIdx,
pathLen: message.pathLen, pathLen: message.pathLen,
pathBytes: message.pathBytes,
textType: message.textType, textType: message.textType,
senderTimestamp: message.senderTimestamp, senderTimestamp: message.senderTimestamp,
text: message.text, text: message.text,
@@ -2104,6 +2462,7 @@ class MessagesProvider with ChangeNotifier {
isVoice: message.isVoice, isVoice: message.isVoice,
voiceId: message.voiceId, voiceId: message.voiceId,
); );
_clearChannelSendWarning(messageId);
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
@@ -2162,11 +2521,7 @@ class MessagesProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
// Cancel all pending timeout timers _cancelAllTimers();
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_completedAckHistory.clear(); _completedAckHistory.clear();
_messageAckHistory.clear(); _messageAckHistory.clear();
_ackHistoryLookup.clear(); _ackHistoryLookup.clear();
@@ -2177,6 +2532,19 @@ class MessagesProvider with ChangeNotifier {
super.dispose(); 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) { void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now(); _completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory(); _cleanupCompletedAckHistory();

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
); );
try { try {
final pcm = await _codec.decodePackets(session.packets, session.mode); final decodedPcm = await _codec.decodePackets(
session.packets,
session.mode,
);
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples'); debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId; _playingSessionId = sessionId;
notifyListeners(); notifyListeners();
await _player.play(pcm); await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) { } catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st'); debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) { if (_playingSessionId == sessionId) {
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
return pcm;
}
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); _sessions.clear();
_outgoingSessions.clear(); _outgoingSessions.clear();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,12 +12,14 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'messages_tab.dart'; import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart'; import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import 'repeaters_map_screen.dart'; import 'repeaters_map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart'; import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -218,6 +220,10 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state; _lifecycleState = state;
@@ -647,6 +653,41 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: const Row( child: const Row(
@@ -672,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( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -713,6 +787,9 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:
return MessagesTab( return MessagesTab(
isActive:
_currentTab == _HomeTab.messages &&
_lifecycleState == AppLifecycleState.resumed,
onNavigateToMap: _isMapEnabled onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map) ? () => _navigateToTab(_HomeTab.map)
: null, : null,
@@ -1056,6 +1133,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12), SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators) if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
context, context,

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ import '../models/message.dart';
import '../services/background_location_service.dart'; import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart'; import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart'; import '../services/trail_color_service.dart';
import '../widgets/map_debug_info.dart'; import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.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 /// Show SAR dialog with pre-populated location from map long press
void _showSarDialogWithLocation(LatLng location) { void _showSarDialogWithLocation(LatLng location) {
// Create a Position object from the LatLng coordinates // Create a Position object from the LatLng coordinates
@@ -1119,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>( return Consumer4<
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) { ContactsProvider,
final contactsWithLocation = contactsProvider.contactsWithLocation; 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 // Filter SAR markers based on visibility toggle
final allSarMarkers = messagesProvider.sarMarkers; final allSarMarkers = messagesProvider.sarMarkers;
final sarMarkers = drawingProvider.showSarMarkers final sarMarkers = drawingProvider.showSarMarkers
@@ -1719,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
onTap: (contact) { onTap: (contact) {
_showDetailedCompassWithContact( _showDetailedCompassWithContact(
context, context,
contactsProvider.contactsWithLocation, contactsWithLocation,
messagesProvider.sarMarkers, messagesProvider.sarMarkers,
contact, contact,
); );
@@ -1731,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context, context: context,
mapRotation: _getMapRotation(), mapRotation: _getMapRotation(),
onTap: (marker) { onTap: (marker) {
// Navigate to the corresponding message in Messages tab _showSarMarkerActions(
messagesProvider.navigateToMessage(marker.id); marker,
widget.onNavigateToMessages?.call(); messagesProvider,
contactsProvider,
);
}, },
), ),
// User location marker with directional pointer // User location marker with directional pointer
@@ -2021,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: GestureDetector( child: GestureDetector(
onTap: () => _showDetailedCompass( onTap: () => _showDetailedCompass(
context, context,
contactsProvider.contactsWithLocation, contactsWithLocation,
messagesProvider.sarMarkers, messagesProvider.sarMarkers,
), ),
child: CompassWidget( child: CompassWidget(

View File

@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget { class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final bool isActive;
const MessagesTab({super.key, this.onNavigateToMap}); const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
@override @override
State<MessagesTab> createState() => _MessagesTabState(); State<MessagesTab> createState() => _MessagesTabState();
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156; static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127; static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148; static const double _composerOverlayHeight = 148;
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
int _messageByteCount = 0; int _messageByteCount = 0;
String? _highlightedMessageId; String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight 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 // Message destination state
String _destinationType = String _destinationType =
@@ -71,7 +79,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const int _maxVoicePackets = 10; static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -85,16 +93,17 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textController.addListener(_updateCharacterCount); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -115,14 +124,24 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void dispose() { void dispose() {
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel(); _voiceStreamSub?.cancel();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@override
void didUpdateWidget(covariant MessagesTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
}
}
void _checkForNavigationRequest() { void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId; final targetMessageId = messagesProvider.targetMessageId;
@@ -144,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
void _scrollToMessage(String messageId) { void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>(); 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) { if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list // 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() { void _updateCharacterCount() {
setState(() { setState(() {
_messageByteCount = utf8.encode(_textController.text).length; _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 => int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel _destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes ? _maxChannelMessageBytes
@@ -338,13 +479,13 @@ class _MessagesTabState extends State<MessagesTab> {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
void _insertReplyMention(String displayName) { void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim(); final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return; if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] '; final mention = '@[$trimmedName] ';
final value = _textController.value; final value = _textController.value;
final selection = value.selection; final selection = replacementRange ?? value.selection;
final hasSelection = final hasSelection =
selection.isValid && selection.isValid &&
selection.start >= 0 && selection.start >= 0 &&
@@ -355,18 +496,30 @@ class _MessagesTabState extends State<MessagesTab> {
final nextText = value.text.replaceRange(start, end, mention); final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length; final nextOffset = start + mention.length;
_suppressMentionTrigger = true;
_textController.value = value.copyWith( _textController.value = value.copyWith(
text: nextText, text: nextText,
selection: TextSelection.collapsed(offset: nextOffset), selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty, composing: TextRange.empty,
); );
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
} }
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async { Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact; Contact? senderContact;
String? senderDisplayName;
String destinationType; String destinationType;
Contact? recipient; Contact? recipient;
@@ -391,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
if (senderPrefix != null && senderPrefix.length >= 6) { if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix); senderContact = contactsProvider.findContactByPrefix(senderPrefix);
} }
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final roomRecipient = message.recipientPublicKey == null
if (senderPrefix == null || senderPrefix.length < 6) { ? null
ToastLogger.error(context, l10n.cannotReplySenderMissing); : contactsProvider.findContactByKey(message.recipientPublicKey!);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix); if (roomRecipient?.isRoom == true) {
if (recipient == null) { destinationType = MessageDestinationPreferences.destinationTypeRoom;
ToastLogger.error(context, l10n.cannotReplyContactNotFound); recipient = roomRecipient;
return;
}
destinationType = recipient.isRoom final senderPrefix = message.senderPublicKeyPrefix;
? MessageDestinationPreferences.destinationTypeRoom if (senderPrefix != null && senderPrefix.length >= 6) {
: MessageDestinationPreferences.destinationTypeContact; 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); await _onRecipientSelected(destinationType, recipient);
if (!mounted) return; if (!mounted) return;
if (message.isChannelMessage && senderContact != null) { if ((message.isChannelMessage || recipient?.isRoom == true) &&
_insertReplyMention(senderContact.displayName); senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
} }
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -467,6 +640,9 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
_textController.clear();
_focusNode.unfocus();
try { try {
// Check destination type and send accordingly // Check destination type and send accordingly
if (_destinationType == if (_destinationType ==
@@ -497,13 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
await _sendToChannel(text, connectionProvider, messagesProvider, 0); await _sendToChannel(text, connectionProvider, messagesProvider, 0);
} }
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead(); _markCurrentDestinationAsRead();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
if (!mounted) return; 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'); ToastLogger.error(context, 'Failed to send: $e');
} }
} }
@@ -817,7 +998,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately. // Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate(); final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -856,11 +1037,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate, _selectedVoiceBitrate,
); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -869,9 +1051,13 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -882,7 +1068,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -899,6 +1085,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -913,9 +1105,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -1104,6 +1301,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1133,16 +1337,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0; var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
}
} }
return peak;
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {
@@ -1677,12 +1893,64 @@ class _MessagesTabState extends State<MessagesTab> {
} }
void _markCurrentDestinationAsRead() { void _markCurrentDestinationAsRead() {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
context.read<MessagesProvider>().markDestinationAsRead( context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType, destinationType: _destinationType,
contact: _selectedRecipient, 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) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1775,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider); _syncChannelAutoReadTimer(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom; final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -1813,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions, onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector, onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,

View File

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

View File

@@ -14,16 +14,18 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_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/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart'; import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
@@ -73,6 +75,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false; bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; 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; bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt; DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false; bool _isClearingOnlineTraceCache = false;
@@ -88,12 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
} }
@override @override
@@ -131,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 { Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt(); final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return; if (!mounted) return;
@@ -178,7 +201,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool('show_rx_tx_indicators', value);
} }
Future<void> _loadVoiceBitratePreference() async { 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(); final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -981,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'), _buildSectionHeader('Navigation'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>( Consumer<AppProvider>(
@@ -1026,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _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( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'), title: const Text('Route path byte size'),
@@ -1155,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'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -1164,6 +1337,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
@@ -1210,6 +1388,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut), secondary: const Icon(Icons.content_cut),
@@ -1729,6 +1944,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled, required bool silenceTrimEnabled,
}) { }) {
final supported = VoiceBitratePreferences.supportedBitrates; final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1741,6 +1959,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) + (bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0); (silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1748,7 +1969,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw); final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration( final voiceDirect = estimateVoiceTransmitDuration(
@@ -1783,7 +2004,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Bitrate: $bitrate bps', 'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -1830,6 +2051,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
enabled: noiseSuppressionEnabled,
),
),
],
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1842,7 +2088,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Processing enabled: $enabledCount/4', 'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -0,0 +1,336 @@
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
class ResolvedContactRoutePlan {
final List<String> tokens;
final List<Contact> selectedContacts;
final String summary;
const ResolvedContactRoutePlan({
required this.tokens,
required this.selectedContacts,
required this.summary,
});
String get canonicalText => tokens.join(',');
}
class ContactRouteResolver {
static const Distance _distance = Distance();
const ContactRouteResolver._();
static ResolvedContactRoutePlan? resolveAutomaticRoute({
required LatLng senderLocation,
required Contact recipient,
required List<Contact> availableContacts,
required int hashSize,
}) {
final recipientLocation = recipient.displayLocation;
if (recipientLocation == null) return null;
final repeaters = availableContacts
.where(
(contact) =>
contact.isRepeater &&
contact.displayLocation != null &&
contact.publicKeyHex != recipient.publicKeyHex,
)
.toList();
if (repeaters.isEmpty) return null;
final recipientLatLng = LatLng(
recipientLocation.latitude,
recipientLocation.longitude,
);
final routedCandidates =
repeaters
.where(
(contact) =>
contact.routeHasPath && contact.routeHashSize == hashSize,
)
.toList()
..sort(
(a, b) =>
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
availableContacts: repeaters,
hashSize: hashSize,
).compareTo(
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
availableContacts: repeaters,
hashSize: hashSize,
),
),
);
if (routedCandidates.isNotEmpty) {
final anchor = routedCandidates.first;
final tokens = <String>[
...anchor.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toUpperCase()),
_tokenFor(anchor, hashSize),
];
final selectedContacts = _matchContactsForTokens(
tokens,
availableContacts: repeaters,
);
return ResolvedContactRoutePlan(
tokens: _dedupeTokens(tokens),
selectedContacts: selectedContacts,
summary: 'Resolved via known repeater route',
);
}
final corridorRepeaters = List<Contact>.from(repeaters)
..sort((a, b) {
final progressA = _progressAlongSegment(
point: LatLng(
a.displayLocation!.latitude,
a.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressB = _progressAlongSegment(
point: LatLng(
b.displayLocation!.latitude,
b.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressCompare = progressA.compareTo(progressB);
if (progressCompare != 0) return progressCompare;
return _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
).compareTo(
_scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
),
);
});
final selected = <Contact>[];
var currentPoint = senderLocation;
var currentDistanceToRecipient = _distance.as(
LengthUnit.Meter,
senderLocation,
recipientLatLng,
);
final maxCorridorDistance = math.max(
1500.0,
currentDistanceToRecipient * 0.22,
);
for (final repeater in corridorRepeaters) {
if (selected.length >= 4) break;
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final distanceToSegment = _distanceToSegmentMeters(
repeaterPoint,
senderLocation,
recipientLatLng,
);
if (distanceToSegment > maxCorridorDistance) {
continue;
}
final nextDistanceToRecipient = _distance.as(
LengthUnit.Meter,
repeaterPoint,
recipientLatLng,
);
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
continue;
}
final distanceFromCurrent = _distance.as(
LengthUnit.Meter,
currentPoint,
repeaterPoint,
);
if (distanceFromCurrent < 100) {
continue;
}
selected.add(repeater);
currentPoint = repeaterPoint;
currentDistanceToRecipient = nextDistanceToRecipient;
if (currentDistanceToRecipient < 2500) {
break;
}
}
if (selected.isEmpty) {
return null;
}
return ResolvedContactRoutePlan(
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
selectedContacts: selected,
summary: 'Resolved from repeater locations',
);
}
static List<Contact> _matchContactsForTokens(
List<String> tokens, {
required List<Contact> availableContacts,
}) {
final matches = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = availableContacts
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
matches.add(match);
}
}
return matches;
}
static List<String> _dedupeTokens(List<String> tokens) {
final result = <String>[];
for (final token in tokens) {
if (result.isEmpty || result.last != token) {
result.add(token);
}
}
return result;
}
static String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
return hex.length < length ? hex : hex.substring(0, length);
}
static double _scoreRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
}) {
final point = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final toRecipient = _distance.as(
LengthUnit.Meter,
point,
recipientLocation,
);
final corridor = _distanceToSegmentMeters(
point,
senderLocation,
recipientLocation,
);
return (toRecipient * 0.75) + (corridor * 0.25);
}
static double _scoreKnownRouteRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
required List<Contact> availableContacts,
required int hashSize,
}) {
final baseScore = _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLocation,
repeater: repeater,
);
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final chainContacts = repeater.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map(
(token) => availableContacts
.where(
(contact) =>
contact.displayLocation != null &&
contact.publicKeyHex.toUpperCase().startsWith(
token.toUpperCase(),
),
)
.firstOrNull,
)
.whereType<Contact>()
.toList();
final chainEnd = chainContacts.isNotEmpty
? LatLng(
chainContacts.last.displayLocation!.latitude,
chainContacts.last.displayLocation!.longitude,
)
: senderLocation;
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
final senderGap = _distance.as(
LengthUnit.Meter,
senderLocation,
repeaterPoint,
);
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
}
static double _progressAlongSegment({
required LatLng point,
required LatLng start,
required LatLng end,
}) {
final dx = end.longitude - start.longitude;
final dy = end.latitude - start.latitude;
final lengthSquared = (dx * dx) + (dy * dy);
if (lengthSquared == 0) return 0;
return (((point.longitude - start.longitude) * dx) +
((point.latitude - start.latitude) * dy)) /
lengthSquared;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -2,13 +2,17 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage /// Service for persisting contacts to local storage
class ContactStorageService { class ContactStorageService {
static const String _contactsKey = 'stored_contacts'; 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 _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage /// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
@@ -90,6 +94,81 @@ class ContactStorageService {
} }
} }
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
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 /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -123,6 +202,7 @@ class ContactStorageService {
'outPathLen': contact.outPathLen, 'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath), 'outPath': base64Encode(contact.outPath),
'advName': contact.advName, 'advName': contact.advName,
'nameOverride': contact.nameOverride,
'lastAdvert': contact.lastAdvert, 'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat, 'advLat': contact.advLat,
'advLon': contact.advLon, 'advLon': contact.advLon,
@@ -130,6 +210,15 @@ class ContactStorageService {
'telemetry': contact.telemetry != null 'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!) ? _telemetryToJson(contact.telemetry!)
: null, : null,
'advertHistory': contact.advertHistory
.map(
(point) => {
'lat': point.location.latitude,
'lon': point.location.longitude,
'tsMillis': point.timestamp.millisecondsSinceEpoch,
},
)
.toList(),
}; };
} }
@@ -145,6 +234,7 @@ class ContactStorageService {
outPathLen: json['outPathLen'] as int, outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)), outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String, advName: json['advName'] as String,
nameOverride: json['nameOverride'] as String?,
lastAdvert: json['lastAdvert'] as int, lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int, advLat: json['advLat'] as int,
advLon: json['advLon'] as int, advLon: json['advLon'] as int,
@@ -152,6 +242,7 @@ class ContactStorageService {
telemetry: json['telemetry'] != null telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>) ? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null, : null,
advertHistory: _advertHistoryFromJson(json['advertHistory']),
); );
} catch (e) { } catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e'); debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
@@ -203,4 +294,56 @@ class ContactStorageService {
return null; return null;
} }
} }
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,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
'matchPrefixes': group.matchPrefixes,
'isAutoGroup': group.isAutoGroup,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
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');
return null;
}
}
} }

View File

@@ -0,0 +1,247 @@
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
String get payloadLabel {
final decodedRoute = route;
if (decodedRoute == null) {
return log.responseCode != null ? log.opcodeName : 'Unknown';
}
return payloadTypeLabel(decodedRoute.payloadType);
}
String? get payloadMeaning {
final decodedRoute = route;
if (decodedRoute == null) return null;
return payloadTypeMeaning(decodedRoute.payloadType);
}
String get routePreview {
final decodedRoute = route;
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
return 'Direct packet';
}
return decodedRoute.hopHashes
.map((hashHex) => '0x${hashHex.toUpperCase()}')
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
}
}
class LiveTrafficSnapshot {
final DateTime windowStart;
final Duration windowDuration;
final int packetsPerMinute;
final int rxCount;
final int txCount;
final int totalCount;
final double? avgSnrDb;
final double? latestSnrDb;
final double? avgRssiDbm;
final int? latestRssiDbm;
final int multiHopCount;
final double? avgHopCount;
final List<LiveTrafficEntry> visibleEntries;
final LiveTrafficBusyness busyness;
const LiveTrafficSnapshot({
required this.windowStart,
required this.windowDuration,
required this.packetsPerMinute,
required this.rxCount,
required this.txCount,
required this.totalCount,
required this.avgSnrDb,
required this.latestSnrDb,
required this.avgRssiDbm,
required this.latestRssiDbm,
required this.multiHopCount,
required this.avgHopCount,
required this.visibleEntries,
required this.busyness,
});
}
class LiveTrafficSummary {
static const Duration rollingWindow = Duration(seconds: 60);
static const int maxVisibleEntries = 120;
static const int logRxDataResponseCode = 0x88;
const LiveTrafficSummary._();
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
DateTime? clearedAt,
int? preferredHashSize,
Duration window = rollingWindow,
String? packetTypeFilter,
}) {
final windowStart = now.subtract(window);
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
final route = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: preferredHashSize,
);
entries.add(LiveTrafficEntry(log: log, route: route));
}
final filteredEntries = packetTypeFilter == null
? entries
: entries
.where((entry) => entry.payloadLabel == packetTypeFilter)
.toList();
var rxCount = 0;
var snrCount = 0;
var snrSum = 0.0;
var rssiCount = 0;
var rssiSum = 0.0;
double? latestSnrDb;
int? latestRssiDbm;
var multiHopCount = 0;
var hopCountTotal = 0;
var hopCountSamples = 0;
for (final entry in filteredEntries) {
rxCount += 1;
final rxInfo = entry.log.logRxDataInfo;
if (rxInfo?.snrDb != null) {
snrCount += 1;
snrSum += rxInfo!.snrDb!;
latestSnrDb = rxInfo.snrDb!;
}
if (rxInfo?.rssiDbm != null) {
rssiCount += 1;
rssiSum += rxInfo!.rssiDbm!.toDouble();
latestRssiDbm = rxInfo.rssiDbm!;
}
final route = entry.route;
if (route != null && route.hopCount > 0) {
hopCountSamples += 1;
hopCountTotal += route.hopCount;
if (route.hopCount > 1) {
multiHopCount += 1;
}
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute =
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
return LiveTrafficSnapshot(
windowStart: effectiveStart,
windowDuration: window,
packetsPerMinute: packetsPerMinute,
rxCount: rxCount,
txCount: txCount,
totalCount: totalCount,
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
latestSnrDb: latestSnrDb,
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);
}
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
if (ppm <= 5) return LiveTrafficBusyness.quiet;
if (ppm <= 20) return LiveTrafficBusyness.active;
return LiveTrafficBusyness.busy;
}
}

View File

@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
class MessageStorageService { class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey = static const String _messageContactLocationsKey =
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey = static const String _messageReceptionDetailsKey =
@@ -19,6 +20,8 @@ class MessageStorageService {
'stored_message_transfer_details'; 'stored_message_transfer_details';
static const String _messageRouteMetadataKey = static const String _messageRouteMetadataKey =
'stored_message_route_metadata'; 'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
@@ -32,8 +35,16 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON // Convert messages to JSON and embed path bytes as a fallback so they
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); // survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent) // Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages final limitedList = jsonList.length > _maxStoredMessages
@@ -129,24 +140,36 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) { if (jsonString != null && jsonString.isNotEmpty) {
final value = entry.value; final decoded = jsonDecode(jsonString);
if (value is! Map<String, dynamic>) continue; if (decoded is Map<String, dynamic>) {
final snapshot = MessageReceptionDetails.fromJson(value); for (final entry in decoded.entries) {
if (snapshot != null) { final value = entry.value;
result[entry.key] = snapshot; if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
} }
} }
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result; return result;
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e'); debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -247,12 +270,36 @@ class MessageStorageService {
await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey); await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey); await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $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 /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -278,7 +325,12 @@ class MessageStorageService {
} }
/// Convert Message to JSON /// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) { Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return { return {
'id': message.id, 'id': message.id,
'messageType': message.messageType.name, 'messageType': message.messageType.name,
@@ -287,6 +339,7 @@ class MessageStorageService {
: null, : null,
'channelIdx': message.channelIdx, 'channelIdx': message.channelIdx,
'pathLen': message.pathLen, 'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value, 'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp, 'senderTimestamp': message.senderTimestamp,
'text': message.text, 'text': message.text,
@@ -338,9 +391,67 @@ class MessageStorageService {
}, },
) )
.toList(), .toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
}; };
} }
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message /// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) { Message? _messageFromJson(Map<String, dynamic> json) {
try { try {
@@ -357,6 +468,14 @@ class MessageStorageService {
: null, : null,
channelIdx: json['channelIdx'] as int?, channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] 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), textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int, senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String, text: json['text'] as String,
@@ -454,4 +573,12 @@ class MessageStorageService {
return null; 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/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -13,14 +14,26 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin = final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin(); 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 _isInitialized = false;
bool _permissionGranted = false; bool _permissionGranted = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs // Notification IDs
static const int _sarNotificationId = 1000; static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000; static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000; static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels // Notification channels
static const String _urgentChannelId = 'sar_urgent'; static const String _urgentChannelId = 'sar_urgent';
@@ -38,12 +51,31 @@ class NotificationService {
static const String _updateChannelDescription = static const String _updateChannelDescription =
'Notifications for available app updates'; '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 /// Initialize notification service
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
try { try {
debugPrint('📬 [NotificationService] Initializing...'); debugPrint('📬 [NotificationService] Initializing...');
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
_lifecycleState =
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
// Initialize timezone data // Initialize timezone data
tz.initializeTimeZones(); tz.initializeTimeZones();
@@ -72,8 +104,15 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse, onDidReceiveNotificationResponse: _onNotificationResponse,
); );
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions // Request permissions
await _requestPermissions(); await _requestPermissions();
await _loadPreferences();
// Create notification channels (Android) // Create notification channels (Android)
await _createNotificationChannels(); 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 /// Create notification channels for Android
Future<void> _createNotificationChannels() async { Future<void> _createNotificationChannels() async {
try { try {
@@ -177,9 +256,31 @@ class NotificationService {
showBadge: true, 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(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel); await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel); await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels'); debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) { } catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $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 /// Show urgent notification for SAR marker
Future<void> showSarNotification({ Future<void> showSarNotification({
required SarMarkerType type, required SarMarkerType type,
@@ -222,6 +329,16 @@ class NotificationService {
); );
return; return;
} }
if (!_sarNotificationsEnabled) {
debugPrint(' [NotificationService] SAR notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping SAR notification',
);
return;
}
try { try {
// Generate unique notification ID based on timestamp // Generate unique notification ID based on timestamp
@@ -394,6 +511,16 @@ class NotificationService {
); );
return; return;
} }
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping message notification',
);
return;
}
try { try {
// Generate unique notification ID based on timestamp // Generate unique notification ID based on timestamp
@@ -547,6 +674,16 @@ class NotificationService {
); );
return; return;
} }
if (!_updateNotificationsEnabled) {
debugPrint(' [NotificationService] Update notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping update notification',
);
return;
}
try { try {
// Build notification title and body // 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

@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v1';
@@ -53,6 +54,7 @@ class PathHistoryService {
pathBytes: contact.routePathBytes.toList(), pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount, hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize, hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -83,15 +85,21 @@ class PathHistoryService {
return; return;
} }
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0')) .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final existing = _findDirectPath(history.directPaths, signature); final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord( final updated = PathRecord(
pathBytes: List<int>.from(pathBytes), pathBytes: normalizedPathBytes,
hopCount: pathBytes.length ~/ hashSize, hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize, hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -191,6 +199,7 @@ class PathHistoryService {
pathBytes: selection.pathBytes.toList(), pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0), successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1), failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success lastRoundTripTimeMs: success

View File

@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -39,14 +43,11 @@ class VoiceCodecService {
} }
} }
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) { Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
} }
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) { Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
@@ -59,24 +60,14 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[]; final all = <Int16List>[];
for (final pkt in packets) { for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) { if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode all.add(Int16List(mode.samplesPerPacket));
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else { } else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); all.add(await decode(pkt.codec2Data, mode));
all.add(decoded);
} }
} }
final total = all.fold<int>(0, (sum, l) => sum + l.length); final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total); final result = Int16List(total);
var offset = 0; var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode { enum Codec2Mode {
mode3200(0), mode3200(0),
mode2400(1), mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond { int get bytesPerSecond {
switch (this) { switch (this) {
case mode3200: return 400; case mode3200:
case mode700c: return 100; return 400;
case mode1200: return 150; case mode700c:
case mode1300: return 175; return 100;
case mode1400: return 175; case mode1200:
case mode1600: return 200; return 150;
case mode2400: return 300; case mode1300:
return 175;
case mode1400:
return 175;
case mode1600:
return 200;
case mode2400:
return 300;
} }
} }
@@ -40,13 +46,20 @@ enum Codec2Mode {
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -66,6 +79,5 @@ class VoiceCodecService {
Future<Int16List> decodePackets( Future<Int16List> decodePackets(
List<VoicePacket?> packets, List<VoicePacket?> packets,
VoicePacketMode mode, VoicePacketMode mode,
) => ) => Future.error(UnsupportedError('Voice not supported on web'));
Future.error(UnsupportedError('Voice not supported on web'));
} }

View File

@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file /// Plays decoded mono PCM samples by writing a WAV file
/// to the system temp directory and using [AudioPlayer]. /// to the system temp directory and using [AudioPlayer].
class VoicePlayerService { class VoicePlayerService {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
}); });
} }
/// Play [pcmSamples] (Int16, 8000 Hz, mono). Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples'); debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop(); if (_isPlaying) await stop();
_position = Duration.zero; _position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000); _duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now(); _playbackStartedAt = DateTime.now();
_events.add(null); _events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory(); final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav'); final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes); await file.writeAsBytes(wavBytes);

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
/// Captures raw PCM audio at 8000 Hz, 16-bit mono. /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
/// ///
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM /// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
/// samples every [chunkDuration]. Call [stopCapture] to end recording. /// samples every [chunkDuration]. Call [stopCapture] to end recording.
@@ -27,12 +27,16 @@ class VoiceRecorderService {
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true. /// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics before encoding. /// [enableCompressor] normalizes speech dynamics before encoding.
/// [enableLimiter] protects against clipping peaks before encoding. /// [enableLimiter] protects against clipping peaks before encoding.
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding. /// The returned stream emits [Int16List] chunks that are ready for voice encoding.
Stream<Int16List> startCapture({ Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1), Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000,
bool enableBandPassFilter = true, bool enableBandPassFilter = true,
bool enableCompressor = true, bool enableCompressor = true,
bool enableLimiter = true, bool enableLimiter = true,
bool enableAutoGain = false,
bool enableEchoCancellation = false,
bool enableNoiseSuppression = false,
}) { }) {
if (_isRecording) { if (_isRecording) {
throw StateError('VoiceRecorderService: already recording'); throw StateError('VoiceRecorderService: already recording');
@@ -43,39 +47,59 @@ class VoiceRecorderService {
_startRecording( _startRecording(
chunkDuration, chunkDuration,
sampleRateHz: sampleRateHz,
enableBandPassFilter: enableBandPassFilter, enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
enableAutoGain: enableAutoGain,
enableEchoCancellation: enableEchoCancellation,
enableNoiseSuppression: enableNoiseSuppression,
); );
return _controller!.stream; return _controller!.stream;
} }
Future<void> _startRecording( Future<void> _startRecording(
Duration chunkDuration, { Duration chunkDuration, {
required int sampleRateHz,
required bool enableBandPassFilter, required bool enableBandPassFilter,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
required bool enableAutoGain,
required bool enableEchoCancellation,
required bool enableNoiseSuppression,
}) async { }) async {
final config = const RecordConfig( final useBandPassFilter = enableBandPassFilter;
final useCompressor = enableCompressor;
final useLimiter = enableLimiter;
final config = RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: 8000, sampleRate: sampleRateHz,
numChannels: 1, numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API bitRate: 128000, // ignored for PCM, but required by API
autoGain: enableAutoGain,
echoCancel: enableEchoCancellation,
noiseSuppress: enableNoiseSuppression,
); );
try { try {
final stream = await _recorder.startStream(config); final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter( final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000, sampleRate: sampleRateHz,
lowCutHz: 250.0, lowCutHz: 250.0,
highCutHz: 3400.0, highCutHz: 3400.0,
); );
final dynamics = _VoiceDynamicsProcessor( final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000, sampleRate: sampleRateHz,
enableCompressor: enableCompressor, thresholdDb: -18.0,
enableLimiter: enableLimiter, ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
enableCompressor: useCompressor,
enableLimiter: useLimiter,
); );
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000; final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[]; final buffer = <int>[];
_sub = stream.listen( _sub = stream.listen(
@@ -85,9 +109,7 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes); final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes); buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk)); final pcm = _bytesToInt16(Uint8List.fromList(chunk));
final filtered = enableBandPassFilter final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered)); _controller?.add(dynamics.process(filtered));
} }
}, },
@@ -95,9 +117,7 @@ class VoiceRecorderService {
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
final padded = _padToEven(buffer); final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded)); final pcm = _bytesToInt16(Uint8List.fromList(padded));
final filtered = enableBandPassFilter final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
? voiceFilter.process(pcm)
: pcm;
_controller?.add(dynamics.process(filtered)); _controller?.add(dynamics.process(filtered));
} }
_controller?.close(); _controller?.close();
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
_VoiceDynamicsProcessor({ _VoiceDynamicsProcessor({
required int sampleRate, required int sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
}) : _enableCompressor = enableCompressor, }) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter, _enableLimiter = enableLimiter,
_compressor = _SimpleCompressor( _compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0, thresholdDb: thresholdDb,
ratio: 2.5, ratio: ratio,
attackMs: 8.0, attackMs: attackMs,
releaseMs: 120.0, releaseMs: releaseMs,
makeupGainDb: 4.0, makeupGainDb: makeupGainDb,
), ),
_limiter = _PeakLimiter(ceilingDb: -1.0); _limiter = _PeakLimiter(ceilingDb: -1.0);

View File

@@ -4,54 +4,98 @@ class InferredContactGroup {
final String key; final String key;
final String label; final String label;
final List<Contact> contacts; final List<Contact> contacts;
final List<String> matchPrefixes;
const InferredContactGroup({ const InferredContactGroup({
required this.key, required this.key,
required this.label, required this.label,
required this.contacts, required this.contacts,
}); List<String>? matchPrefixes,
}) : matchPrefixes = matchPrefixes ?? const <String>[];
DateTime get latestSeen => contacts.first.lastSeenTime; 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 { class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp( static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])', 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) { static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts) return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); ..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
} }
static List<ContactListItem> buildItems( static List<InferredContactGroup> inferGroups(
List<Contact> contacts, { List<Contact> contacts, {
int minGroupSize = 4, int minGroupSize = 4,
int? maxNamedGroups,
String? overflowGroupLabel,
}) { }) {
final sortedContacts = sortByLastSeen(contacts); 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 groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{}; final groupLabels = <String, String>{};
@@ -62,32 +106,46 @@ class ContactGrouping {
groupLabels.putIfAbsent(prefix.key, () => prefix.label); groupLabels.putIfAbsent(prefix.key, () => prefix.label);
} }
final eligibleGroups = <String, InferredContactGroup>{}; final rankedGroups = <InferredContactGroup>[];
for (final entry in groupedContacts.entries) { for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue; if (entry.value.length < minGroupSize) continue;
eligibleGroups[entry.key] = InferredContactGroup( rankedGroups.add(
key: entry.key, InferredContactGroup(
label: groupLabels[entry.key] ?? entry.key, key: entry.key,
contacts: entry.value, 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>{}; if (maxNamedGroups != null &&
final items = <ContactListItem>[]; 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) { return rankedGroups;
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;
} }
static _GroupPrefix? _extractPrefix(String name) { static _GroupPrefix? _extractPrefix(String name) {

View File

@@ -30,6 +30,8 @@ class ResolvedNodeHash {
final bool isOwnNode; final bool isOwnNode;
final bool isUniqueMatch; final bool isUniqueMatch;
final int matchCount; final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({ const ResolvedNodeHash({
required this.hashHex, required this.hashHex,
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
required this.isOwnNode, required this.isOwnNode,
required this.isUniqueMatch, required this.isUniqueMatch,
required this.matchCount, required this.matchCount,
this.latitude,
this.longitude,
}); });
String get hexLabel => '0x${hashHex.toUpperCase()}'; String get hexLabel => '0x${hashHex.toUpperCase()}';
@@ -154,11 +158,33 @@ class LogRxRouteDecoder {
return hops; return hops;
} }
static List<int> reverseHopBytes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return List<int>.from(pathBytes.reversed);
}
final reversed = <int>[];
for (
var index = pathBytes.length - hashSize;
index >= 0;
index -= hashSize
) {
reversed.addAll(pathBytes.sublist(index, index + hashSize));
}
return reversed;
}
static ResolvedNodeHash resolveHash( static ResolvedNodeHash resolveHash(
String hashHex, { String hashHex, {
required Iterable<Contact> contacts, required Iterable<Contact> contacts,
Uint8List? ownPublicKey, Uint8List? ownPublicKey,
String? ownName, String? ownName,
double? ownLatitude,
double? ownLongitude,
}) { }) {
final normalizedHashHex = hashHex.toLowerCase(); final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey); final ownKeyHex = _bytesToHex(ownPublicKey);
@@ -172,6 +198,8 @@ class LogRxRouteDecoder {
isOwnNode: true, isOwnNode: true,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
); );
} }
@@ -190,12 +218,15 @@ class LogRxRouteDecoder {
} }
if (matches.length == 1) { if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash( return ResolvedNodeHash(
hashHex: normalizedHashHex, hashHex: normalizedHashHex,
label: matches.first.displayName, label: matches.first.displayName,
isOwnNode: false, isOwnNode: false,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
); );
} }

View File

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

View File

@@ -0,0 +1,239 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final List<MeshMapNode> candidates;
final int matchCount;
final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({
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 {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
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,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
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;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -11,20 +11,31 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1; const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
/// Identifies which Codec2 mode was used for a voice packet. /// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header. /// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode { enum VoicePacketMode {
mode700c(0, '700C'), mode700c(0, '700C', 8000, 100, 1600),
mode1200(1, '1200'), mode1200(1, '1200', 8000, 150, 1040),
mode2400(2, '2400'), mode2400(2, '2400', 8000, 300, 520),
mode1300(3, '1300'), mode1300(3, '1300', 8000, 175, 880),
mode1400(4, '1400'), mode1400(4, '1400', 8000, 175, 880),
mode1600(5, '1600'), mode1600(5, '1600', 8000, 200, 800),
mode3200(6, '3200'); mode3200(6, '3200', 8000, 400, 400);
const VoicePacketMode(this.id, this.label); const VoicePacketMode(
this.id,
this.label,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id; final int id;
final String label; final String label;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere( static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id, (m) => m.id == id,
@@ -153,23 +164,21 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds. /// Estimated audio duration of this packet in milliseconds.
int get durationMs { int get durationMs {
// bytesPerSecond for each mode try {
final bps = switch (mode) { final bytesPerSecond = voiceModeBytesPerSecond(mode);
VoicePacketMode.mode700c => 100, if (bytesPerSecond <= 0) return 0;
VoicePacketMode.mode1200 => 150, return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
VoicePacketMode.mode1300 => 175, } catch (_) {
VoicePacketMode.mode1400 => 175, // Be permissive with stale or malformed persisted voice metadata.
VoicePacketMode.mode1600 => 200, return 0;
VoicePacketMode.mode2400 => 300, }
VoicePacketMode.mode3200 => 400,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
} }
@override @override
String toString() { String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]'; final suffix = total > 0
? ' ${mode.label} [$index/${total - 1}]'
: ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)'; return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
} }
} }
@@ -244,10 +253,10 @@ class VoiceEnvelope {
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
VoicePacketMode.mode700c => 100, VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150, VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode1300 => 175, VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175, VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200, VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400, VoicePacketMode.mode3200 => 400,
}; };
@@ -419,8 +428,7 @@ class VoiceFetchRequest {
this.version = 3, this.version = 3,
}); });
static bool isVoiceFetchRequestText(String text) => static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) => static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -473,9 +481,7 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
? 'missing'
: wantToken);
if (sid == null) { if (sid == null) {
return null; return null;
@@ -602,7 +608,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) { String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
} }
final value = int.parse(sessionIdHex, radix: 16); final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36); return value.toRadixString(36);
@@ -616,10 +626,7 @@ String? _decodeSessionId(String token) {
} }
String _encodeMissingIndicesCompact(List<int> indices) { String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort(); ..sort();
if (sorted.isEmpty) return ''; if (sorted.isEmpty) return '';
final chunks = <String>[]; final chunks = <String>[];
@@ -632,7 +639,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue; continue;
} }
chunks.add( chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
); );
start = curr; start = curr;
prev = curr; prev = curr;

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 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// Dialog for adding a new channel /// Bottom sheet for adding a new channel
class AddChannelDialog extends StatefulWidget { class AddChannelSheet extends StatefulWidget {
final Future<void> Function(String name, String secret) onCreateChannel; final Future<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({ const AddChannelSheet({super.key, required this.onCreateChannel});
super.key,
required this.onCreateChannel,
});
@override @override
State<AddChannelDialog> createState() => _AddChannelDialogState(); State<AddChannelSheet> createState() => _AddChannelSheetState();
} }
class _AddChannelDialogState extends State<AddChannelDialog> { class _AddChannelSheetState extends State<AddChannelSheet> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _secretController = TextEditingController(); final _secretController = TextEditingController();
@@ -109,152 +106,183 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
final normalizedName = _nameController.text.trimLeft(); final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#'); final isHashChannel = normalizedName.startsWith('#');
return AlertDialog( return ConstrainedBox(
title: Text(l10n.addChannel), constraints: BoxConstraints(
content: SingleChildScrollView( maxHeight: MediaQuery.of(context).size.height * 0.85,
child: Form( ),
key: _formKey, child: SingleChildScrollView(
child: Column( child: Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
crossAxisAlignment: CrossAxisAlignment.start, child: Form(
children: [ key: _formKey,
// Info banner explaining channel types child: Column(
Container( mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.all(12), crossAxisAlignment: CrossAxisAlignment.start,
decoration: BoxDecoration( children: [
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), Row(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(
Icons.info_outline,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
l10n.channelTypesInfo, l10n.addChannel,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant, 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), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5), color: theme.colorScheme.primaryContainer.withValues(
borderRadius: BorderRadius.circular(8), alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
),
), ),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon( Icon(
Icons.auto_awesome, Icons.info_outline,
size: 20, size: 20,
color: Colors.blue, color: theme.colorScheme.primary,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
l10n.hashChannelInfo, l10n.channelTypesInfo,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
], ],
), ),
), ),
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),
),
],
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
@@ -20,6 +21,8 @@ import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {
final Contact contact; final Contact contact;
final String? groupLabel;
final bool compact;
final Position? currentPosition; final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance; final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance; final String Function(double)? formatDistance;
@@ -29,6 +32,8 @@ class ContactTile extends StatelessWidget {
const ContactTile({ const ContactTile({
super.key, super.key,
required this.contact, required this.contact,
this.groupLabel,
this.compact = false,
this.currentPosition, this.currentPosition,
this.calculateDistance, this.calculateDistance,
this.formatDistance, this.formatDistance,
@@ -124,31 +129,46 @@ class ContactTile extends StatelessWidget {
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final Widget subtitleWidget = Column( final Widget subtitleWidget = compact
crossAxisAlignment: CrossAxisAlignment.start, ? _buildCompactSubtitle(context, distanceText)
children: [ : Column(
if (location != null) ...[ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 2), children: [
_buildLocationLine( const SizedBox(height: 4),
context, Wrap(
latitude: location.latitude, spacing: 6,
longitude: location.longitude, runSpacing: 6,
distanceText: distanceText, children: [
), if (groupLabel case final label?)
const SizedBox(height: 6), _buildMetaPill(
Row(children: [_buildRoutePill(context, contact)]), context,
] else icon: Icons.folder_copy_outlined,
Padding( label: label,
padding: const EdgeInsets.only(top: 4), ),
child: Text( ],
AppLocalizations.of(context)!.noGpsData, ),
style: Theme.of( if (location != null) ...[
context, const SizedBox(height: 2),
).textTheme.labelSmall?.copyWith(color: Colors.grey), _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( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -272,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) { void _handlePrimaryTap(BuildContext context, Contact contact) {
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
@@ -283,7 +325,9 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room || contact.type == ContactType.room ||
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater;
final canAddToSensors = final canAddToSensors =
contact.type == ContactType.chat || contact.type == ContactType.chat ||
contact.type == ContactType.repeater; contact.type == ContactType.repeater;
@@ -296,95 +340,115 @@ class ContactTile extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
ListTile( children: [
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)
ListTile( ListTile(
leading: const Icon(Icons.map_outlined), leading: const Icon(Icons.message_outlined),
title: Text(l10n.viewOnMap), title: Text(l10n.messages),
onTap: () { enabled: canMessage,
Navigator.pop(sheetContext); onTap: !canMessage
_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
? null ? null
: () async { : () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await _addContactToSensors(context, contact); await _openMessagesForContact(context, contact);
}, },
), ),
if (canSetPath) if (contact.displayLocation != null)
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.map_outlined),
title: Text(l10n.contactSetPath), title: Text(l10n.viewOnMap),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact); _showContactOnMap(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 { if (contact.type == ContactType.room && !contact.isPublicChannel)
Navigator.pop(sheetContext); ListTile(
await Future<void>.delayed(Duration.zero); leading: const Icon(Icons.login),
if (!context.mounted) return; title: Text(
WidgetsBinding.instance.addPostFrameCallback((_) { 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 (!context.mounted) return;
if (contact.isChannel) { WidgetsBinding.instance.addPostFrameCallback((_) {
_showDeleteChannelDialog(context, contact); if (!context.mounted) return;
} else { if (contact.isChannel) {
_showDeleteConfirmation(context, contact); _showDeleteChannelDialog(context, contact);
} } else {
}); _showDeleteConfirmation(context, contact);
}, }
), });
], },
),
],
),
), ),
), ),
); );
@@ -453,6 +517,18 @@ class ContactTile extends StatelessWidget {
); );
} }
void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => ContactTraceSheet(contact: contact),
);
}
void _showDeleteConfirmation( void _showDeleteConfirmation(
BuildContext context, BuildContext context,
Contact contact, { Contact contact, {
@@ -512,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( Future<void> _showSetRouteDialog(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -569,6 +679,7 @@ class ContactTile extends StatelessWidget {
contact.publicKey, contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
inferredFallbackLocation: routeResult.inferredFallbackLocation,
); );
try { try {
@@ -649,6 +760,38 @@ class ContactTile extends StatelessWidget {
); );
} }
Widget _buildMetaPill(
BuildContext context, {
required IconData icon,
required String label,
bool monospace = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
],
),
);
}
Widget _buildLocationLine( Widget _buildLocationLine(
BuildContext context, { BuildContext context, {
required double latitude, required double latitude,
@@ -838,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

@@ -0,0 +1,534 @@
import 'dart:async';
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 '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../utils/trace_node_resolver.dart';
class ContactTraceSheet extends StatefulWidget {
final Contact contact;
const ContactTraceSheet({super.key, required this.contact});
@override
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
}
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
_ContactTraceResult? _traceOverride;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_ContactTraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final localNodes = _localNodesFromContacts(
contactsProvider,
connectionProvider: connectionProvider,
);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
if (_isCompleteTrace(trace)) {
return trace;
}
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
return trace;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_ContactTraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Failed to load trace: ${snapshot.error}'),
),
),
);
}
final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.toList();
final mapPoints = concreteNodes
.map((node) => LatLng(node.latitude, node.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = trace.matchedRelayNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? '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,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: concreteNodes
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor:
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'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: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'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),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relays (${relayNodes.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (relayNodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this contact.',
),
),
...relayNodes.map(
(node) => ListTile(
leading: const Icon(Icons.router),
title: Text(node.name),
subtitle: Text(
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))}'
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
),
),
),
],
),
),
],
),
);
},
),
);
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
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) =>
publicKey.substring(0, math.min(12, publicKey.length));
bool _isCompleteTrace(_ContactTraceResult trace) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.routeHashes.isEmpty) {
return true;
}
return trace.matchedRelayNodes.every((node) => node.node != null);
}
_ContactTraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? selfPublicKey,
}) {
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(selfPublicKey),
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(widget.contact.publicKey),
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
final routeHashes =
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
? widget.contact.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toLowerCase())
.toList()
: const <String>[];
final matchedRelayNodes = routeHashes
.map(
(hash) => TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hash,
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
)
.toList();
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
nodes: matchedRelayNodes,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: alignedRelayNodes,
);
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
List<MeshMapNode> _localNodesFromContacts(
ContactsProvider contactsProvider, {
required ConnectionProvider connectionProvider,
}) {
final nodes = contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.toList();
final selfNode = _selfNode(connectionProvider);
if (selfNode != null) {
nodes.add(selfNode);
}
return nodes;
}
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
final publicKey = connectionProvider.deviceInfo.publicKey;
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (publicKey == null || advLat == null || advLon == null) {
return null;
}
if (advLat == 0 && advLon == 0) {
return null;
}
return MeshMapNode(
type: -1,
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: 'You',
publicKey: publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase(),
latitude: advLat / 1e6,
longitude: advLon / 1e6,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
}
class _ContactTraceResult {
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> routeHashes;
final List<ResolvedTraceNode> matchedRelayNodes;
const _ContactTraceResult({
required this.sender,
required this.recipient,
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 {
final ResolvedTraceNode resolved;
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;
}
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) /// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget { class MessageBubble extends StatefulWidget {
final Message message; final Message message;
final int receivedCopies;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReply; final VoidCallback? onReply;
final bool isHighlighted; final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
this.receivedCopies = 1,
this.onTap, this.onTap,
this.onReply, this.onReply,
this.isHighlighted = false, this.isHighlighted = false,
@@ -104,6 +106,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final textColor = final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of( final backgroundColor = Theme.of(
context, context,
).colorScheme.primary.withValues(alpha: 0.12); ).colorScheme.primary.withValues(alpha: 0.12);
@@ -130,8 +133,8 @@ class _MessageBubbleState extends State<MessageBubble> {
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1), margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -141,8 +144,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'@$mentionName', '@$mentionName',
style: baseBodyStyle?.copyWith( style: baseBodyStyle?.copyWith(
color: textColor, color: textColor,
fontWeight: FontWeight.w700, fontSize: mentionFontSize,
height: 1.1, fontWeight: FontWeight.w600,
height: 1.0,
), ),
), ),
), ),
@@ -574,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -844,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
), ),
if (widget.receivedCopies > 1)
_detailRow(
context,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
context, context,
@@ -1788,6 +1799,7 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails?.rssiDbm ?? receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ?? matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm; message.lastEchoRssiDbm;
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name // Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -2071,9 +2083,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Text( child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage color: isOwnMessage
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,
@@ -2515,7 +2529,7 @@ class _MessageBubbleState extends State<MessageBubble> {
switch (recipient.deliveryStatus) { switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
statusColor = Colors.green; statusColor = Colors.green;
statusIcon = Icons.check_circle; statusIcon = Icons.done_all;
statusText = statusText =
recipient.roundTripTimeMs != null recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms' ? '${recipient.roundTripTimeMs}ms'
@@ -2523,6 +2537,15 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
)!.delivered; )!.delivered;
break; break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
statusColor = Colors.red; statusColor = Colors.red;
statusIcon = Icons.cancel; statusIcon = Icons.cancel;
@@ -2531,7 +2554,6 @@ class _MessageBubbleState extends State<MessageBubble> {
)!.failed; )!.failed;
break; break;
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
case MessageDeliveryStatus.sent:
default: default:
statusColor = Colors.orange; statusColor = Colors.orange;
statusIcon = Icons.schedule; statusIcon = Icons.schedule;
@@ -2726,6 +2748,7 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
message: message, message: message,
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
), ),
], ],
); );

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../utils/avatar_label_helper.dart'; import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
BuildContext context, { BuildContext context, {
required Message message, required Message message,
required bool isSarMarker, required bool isSarMarker,
MessageRouteMetadata? routeMetadata,
}) { }) {
final metaColor = Theme.of( final metaColor = Theme.of(
context, context,
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
).textTheme.labelSmall?.copyWith(color: metaColor), ).textTheme.labelSmall?.copyWith(color: metaColor),
), ),
]); ]);
} else if (!isSarMarker && message.pathLen < 255) { } else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
final effectivePathLen = _effectivePathLen(message, routeMetadata);
items.addAll([ items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor), Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3), const SizedBox(width: 3),
Text( Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop', effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: metaColor), ).textTheme.labelSmall?.copyWith(color: metaColor),
@@ -124,17 +127,27 @@ Widget buildBubbleMetaFooter(
); );
} }
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
routeMetadata?.hopCount ?? message.pathLen;
Widget buildChannelHeaderPill( Widget buildChannelHeaderPill(
BuildContext context, { BuildContext context, {
required String label, required String label,
IconData icon = Icons.campaign_outlined, IconData icon = Icons.campaign_outlined,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
double iconSize = 11,
double iconSpacing = 5,
TextStyle? textStyle,
}) { }) {
final labelColor = Theme.of( final labelColor = Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82); ).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
@@ -146,19 +159,21 @@ Widget buildChannelHeaderPill(
children: [ children: [
Icon( Icon(
icon, icon,
size: 11, size: iconSize,
color: Theme.of( color: Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7), ).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
), ),
const SizedBox(width: 5), SizedBox(width: iconSpacing),
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style:
color: labelColor, textStyle ??
fontWeight: FontWeight.w600, Theme.of(context).textTheme.labelSmall?.copyWith(
), color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -176,5 +191,16 @@ Widget buildDirectHeaderCounterpart(
context, context,
label: label, label: label,
icon: Icons.alternate_email, icon: Icons.alternate_email,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
iconSize: 10,
iconSpacing: 4,
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 10,
height: 1.0,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart'; import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart'; import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
_techChip( _techChip(
context, context,
icon: Icons.alt_route, icon: Icons.alt_route,
label: hopDisplayLabel(message), label: hopDisplayLabelForMessage(message, routeMetadata),
color: Colors.indigo, color: Colors.indigo,
), ),
_techChip( _techChip(
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
} }
String hopDisplayLabelForMessage(
Message message,
MessageRouteMetadata? routeMetadata,
) {
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return 'Direct';
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
if (effectivePathLen >= 255) return 'Unknown';
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
}
Widget _techChip( Widget _techChip(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -10,14 +10,31 @@ import '../../models/ble_packet_log.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget { 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 @override
State<MessageTraceSheet> createState() => _MessageTraceSheetState(); State<MessageTraceSheet> createState() => _MessageTraceSheetState();
@@ -25,6 +42,7 @@ class MessageTraceSheet extends StatefulWidget {
class _MessageTraceSheetState extends State<MessageTraceSheet> { class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future; late final Future<_TraceResult> _future;
_TraceResult? _traceOverride;
@override @override
void initState() { void initState() {
@@ -35,20 +53,34 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs( List<int>? packetPath = widget.packetPathOverride;
logs: connectionProvider.bleService.packetLogs, String? senderPrefix;
message: widget.message, String? recipientPrefix;
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); if (widget.message case final message?) {
final recipientPrefix = widget.message.recipientPublicKey != null final storedPath = messagesProvider
? _toPrefixHex(widget.message.recipientPublicKey) .getMessageReceptionDetails(message.id)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); ?.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 localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
nodes: localNodes, nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -56,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
if (_isCompleteTrace( if (_isCompleteTrace(
trace, trace,
expectedRelayCount: math.max(0, widget.message.pathLen), expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) { )) {
return trace; return trace;
} }
@@ -72,6 +106,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
trace = _buildTraceResult( trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes), nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -104,11 +139,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
} }
final trace = snapshot.data!; final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries final concretePathNodes = routeEntries
.where((entry) => entry.node != null) .where((entry) => entry.resolved.node != null)
.map((entry) => entry.node!) .map((entry) => entry.resolved.node!)
.toList(); .toList();
final mapPoints = concretePathNodes final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude)) .map((n) => LatLng(n.latitude, n.longitude))
@@ -142,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
trace.mode == TraceMode.packetPath widget.descriptionOverride ??
? 'Route from packet path bytes' (trace.mode == TraceMode.packetPath
: 'Route inferred from hop count (${widget.message.pathLen})', ? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@@ -211,14 +247,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: backgroundColor:
entry.key == 0 Colors.blue,
? Colors.green
: (entry.key ==
concretePathNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -248,29 +277,35 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
'Route', 'Relay path',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
if (routeEntries.isEmpty) if (routeEntries.isEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
child: Text( 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( ...routeEntries.asMap().entries.map(
(entry) => ListTile( (entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: Colors.blue,
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -282,8 +317,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}', '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), const SizedBox(height: 12),
@@ -326,10 +364,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<MeshMapNode> _relayNodes(_TraceResult trace) { List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final concrete = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (concrete.isEmpty) return const []; if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
if (widget.message == null) {
return concrete;
}
if (concrete.length <= 1) return const []; if (concrete.length <= 1) return const [];
return concrete.sublist(1); return concrete.sublist(1);
} }
@@ -339,55 +383,37 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
if (trace.recipient != null &&
trace.recipient!.publicKey != trace.sender?.publicKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
if (trace.mode == TraceMode.packetPath) { 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(); final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: entry.value, resolved: entry.value,
label: entry.value?.name ?? 'Unknown', label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value != null keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value!.publicKey) ? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex, : hashHex,
matchSummary: entry.value.matchSummary,
target: _RouteEntryTarget.pathNode(entry.key),
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
} }
final firstKey = pathNodes.first.publicKey; return trace.matchedPathNodes
final lastKey = pathNodes.last.publicKey; .asMap()
return [ .entries
if (trace.sender != null && trace.sender!.publicKey != firstKey) .where((entry) => entry.value.node != null)
_RouteDisplayEntry.fromNode(trace.sender!), .map(
...pathNodes.map(_RouteDisplayEntry.fromNode), (entry) => _RouteDisplayEntry.fromResolved(
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) entry.value,
_RouteDisplayEntry.fromNode(trace.recipient!), target: _RouteEntryTarget.pathNode(entry.key),
]; ),
)
.toList();
} }
String _prefixKeyLabel(String publicKey) => String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length)); 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) { String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null; if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6; final take = key.length < 6 ? key.length : 6;
@@ -398,14 +424,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.toLowerCase(); .toLowerCase();
} }
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null;
final matches =
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first;
}
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) { List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation return contactsProvider.contactsWithLocation
.map((contact) { .map((contact) {
@@ -440,13 +458,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({ _TraceResult _buildTraceResult({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath, required List<int>? packetPath,
required int preferredHashSize, required int preferredHashSize,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix); final senderNode = TraceNodeResolver.resolveBest(
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: senderPrefix,
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: recipientPrefix,
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
if (packetPath != null && packetPath.isNotEmpty) { if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize( final hashSize = LogRxRouteDecoder.inferHashSize(
@@ -459,29 +492,43 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes, pathHashes: hopHashes,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
final alignedMatched = TraceNodeResolver.alignPathSelections(
nodes: matched,
startNode: senderNode.node,
endNode: recipientNode.node,
); );
return _TraceResult( return _TraceResult(
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
sender: senderNode, sender: senderNode,
recipient: recipientNode, recipient: recipientNode,
pathHashes: hopHashes, pathHashes: hopHashes,
matchedPathNodes: matched, matchedPathNodes: alignedMatched,
); );
} }
final inferred = _inferRelaysFromHopCount( final inferred = _inferRelaysFromHopCount(
nodes: nodes, nodes: nodes,
sender: senderNode, sender: senderNode.node,
recipient: recipientNode, recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen), relayCount: math.max(0, widget.message!.pathLen),
); );
final matchedPathNodes = <MeshMapNode?>[ final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode != null) senderNode, if (senderNode.node != null) senderNode,
...inferred, ...inferred.map(
if (recipientNode != null) recipientNode, (node) => ResolvedTraceNode(
candidates: [node],
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
]; ];
return _TraceResult( return _TraceResult(
@@ -494,16 +541,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) { bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender == null || trace.recipient == null) { if (trace.sender.node == null || trace.recipient.node == null) {
return false; return false;
} }
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length && return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null); trace.matchedPathNodes.every((node) => node.node != null);
} }
final concreteCount = trace.matchedPathNodes final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>() .whereType<MeshMapNode>()
.length; .length;
return concreteCount >= expectedRelayCount + 2; return concreteCount >= expectedRelayCount + 2;
@@ -544,33 +592,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return decoded.pathBytes; return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<String> pathHashes, required List<String> pathHashes,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) { }) {
final result = <MeshMapNode?>[]; final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toLowerCase(); final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes result.add(
.where((n) => n.publicKey.startsWith(hashHex)) TraceNodeResolver.resolveBest(
.toList(); nodes: nodes,
if (candidates.isEmpty) { localPublicKeys: localPublicKeys,
result.add(null); prefixHex: hashHex,
continue; preferredPrefix: i == 0
} ? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
List<MeshMapNode> filtered = candidates; referenceA: senderLatLng,
if (i == 0 && senderPrefix != null) { referenceB: recipientLatLng,
final senderMatches = filtered ),
.where((n) => n.publicKey.startsWith(senderPrefix)) );
.toList();
if (senderMatches.isNotEmpty) filtered = senderMatches;
}
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
result.add(filtered.first);
} }
return result; return result;
} }
@@ -639,10 +684,10 @@ enum TraceMode { packetPath, hopCountInference }
class _TraceResult { class _TraceResult {
final TraceMode mode; final TraceMode mode;
final MeshMapNode? sender; final ResolvedTraceNode sender;
final MeshMapNode? recipient; final ResolvedTraceNode recipient;
final List<String> pathHashes; final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes; final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({ const _TraceResult({
required this.mode, required this.mode,
@@ -651,27 +696,66 @@ class _TraceResult {
required this.pathHashes, required this.pathHashes,
required this.matchedPathNodes, 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 { class _RouteDisplayEntry {
final MeshMapNode? node; final ResolvedTraceNode resolved;
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.node, required this.resolved,
required this.label, required this.label,
required this.keyLabel, required this.keyLabel,
required this.matchSummary,
required this.target,
}); });
factory _RouteDisplayEntry.fromNode(MeshMapNode node) { MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: node, resolved: resolved,
label: node.name, label: node.name,
keyLabel: node.publicKey.substring( keyLabel: node.publicKey.substring(
0, 0,
math.min(12, node.publicKey.length), 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 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../common/contact_avatar.dart';
class MessagesComposer extends StatelessWidget { class MessagesComposer extends StatelessWidget {
final TextEditingController textController; final TextEditingController textController;
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding; final double bottomPadding;
final String destinationLabel; final String destinationLabel;
final Widget destinationAvatar; final Widget destinationAvatar;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
final VoidCallback onShowComposerActions; final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector; final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording; final Future<void> Function() onStartVoiceRecording;
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding, required this.bottomPadding,
required this.destinationLabel, required this.destinationLabel,
required this.destinationAvatar, required this.destinationAvatar,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions, required this.onShowComposerActions,
required this.onShowRecipientSelector, required this.onShowRecipientSelector,
required this.onStartVoiceRecording, required this.onStartVoiceRecording,
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (mentionSuggestions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
child: _MentionSuggestionsCard(
suggestions: mentionSuggestions,
query: mentionQuery,
onSelected: onMentionSelected,
),
),
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: Padding(
@@ -72,7 +89,7 @@ class MessagesComposer extends StatelessWidget {
], ],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -95,7 +112,7 @@ class MessagesComposer extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
ListenableBuilder( ListenableBuilder(
listenable: Listenable.merge([ listenable: Listenable.merge([
textController, 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 { class _ComposerActionButton extends StatelessWidget {
final bool isRecording; final bool isRecording;
final VoidCallback onPressed; final VoidCallback onPressed;
@@ -165,8 +263,8 @@ class _ComposerActionButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: 42, width: 40,
height: 42, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -175,7 +273,7 @@ class _ComposerActionButton extends StatelessWidget {
), ),
), ),
child: IconButton( 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', tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed, onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary, color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
@@ -203,7 +301,7 @@ class _DestinationSelector extends StatelessWidget {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: onTap, onTap: onTap,
child: Ink( child: Ink(
height: 42, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -212,17 +310,17 @@ class _DestinationSelector extends StatelessWidget {
), ),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
children: [ children: [
destinationAvatar, destinationAvatar,
const SizedBox(width: 10), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
destinationLabel, destinationLabel,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
@@ -230,7 +328,7 @@ class _DestinationSelector extends StatelessWidget {
), ),
Icon( Icon(
Icons.expand_more_rounded, Icons.expand_more_rounded,
size: 20, size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
], ],
@@ -261,7 +359,7 @@ class _MessageInput extends StatelessWidget {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132), constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
@@ -284,7 +382,7 @@ class _MessageInput extends StatelessWidget {
: null, : null,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: TextField( child: TextField(
controller: textController, controller: textController,
focusNode: focusNode, focusNode: focusNode,
@@ -375,8 +473,8 @@ class _SendButton extends StatelessWidget {
children: [ children: [
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
width: 46, width: 42,
height: 46, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: canSendText || isRecording color: canSendText || isRecording
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
@@ -410,7 +508,7 @@ class _SendButton extends StatelessWidget {
) )
: Icon( : Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded, isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22, size: 20,
color: canSendText || isRecording color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary ? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant, : Theme.of(context).colorScheme.onSurfaceVariant,

View File

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

View File

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

View File

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

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: main ref: "813f5b3"
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460 resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" 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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0311.1+26 version: 2026.0314.1+30
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: main ref: "813f5b3"
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: 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

@@ -1,8 +1,10 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.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/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart'; import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
@@ -426,6 +428,46 @@ void main() {
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001)); 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', () { group('ContactsProvider route updates', () {
@@ -457,6 +499,24 @@ void main() {
expect(updated.routeCanonicalText, 'AABB,CCDD'); expect(updated.routeCanonicalText, 'AABB,CCDD');
}); });
test('stores inferred fallback gps when route is set locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
const fallback = LatLng(46.1001, 14.5002);
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
inferredFallbackLocation: fallback,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
expect(updated.advertHistory, isNotEmpty);
});
test('resetContactRouteLocal clears route state', () { test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC'); final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal( provider.setContactRouteLocal(
@@ -471,6 +531,153 @@ void main() {
expect(updated.routeHasPath, isFalse); expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown'); expect(updated.routeSummary, 'Flood/Unknown');
}); });
test('retains existing route when contact refresh omits path', () {
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
name: 'Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('applies retained pending advert route when contact is resolved', () {
final pendingKey = createPublicKey(96);
final retainedRoute = ContactRouteCodec.parse('1122,3344');
provider.retainReceivedRoute(
pendingKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addPendingAdvert(pendingKey);
provider.addOrUpdateContact(
createContact(
key: pendingKey,
type: ContactType.chat,
name: 'Pending Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(pendingKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, '1122,3344');
expect(
provider.pendingAdverts.where(
(advert) => advert.publicKeyHex == updated.publicKeyHex,
),
isEmpty,
);
});
test('infers a fallback location 100m from last-hop repeater', () {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(120);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'No GPS',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
final inferred = updated.displayLocation;
final repeater = provider.findContactByKey(repeaterKey)!;
final repeaterLocation = repeater.displayLocation;
expect(inferred, isNotNull);
expect(repeaterLocation, isNotNull);
final distanceMeters = Geolocator.distanceBetween(
repeaterLocation!.latitude,
repeaterLocation.longitude,
inferred!.latitude,
inferred.longitude,
);
expect(distanceMeters, closeTo(100.0, 8.0));
});
test(
'does not infer a fallback location when the contact advertises one',
() {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(121);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'Has Advert',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (45.1234 * 1e6).toInt(),
advLon: (13.8765 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
},
);
}); });
group('ContactsProvider.updateFastGps', () { group('ContactsProvider.updateFastGps', () {
@@ -533,4 +740,79 @@ void main() {
expect(after.advLon, equals(before.advLon)); expect(after.advLon, equals(before.advLon));
}); });
}); });
group('ContactsProvider saved contact groups', () {
late ContactsProvider provider;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
});
test('adds and removes saved groups by filter', () async {
expect(provider.savedContactGroups, isEmpty);
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
expect(provider.savedContactGroups, hasLength(1));
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
expect(provider.savedContactGroups, isEmpty);
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
});
test('loads persisted saved groups during initialization', () async {
await provider.addSavedGroupForFilter('rooms', 'ops');
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
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:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.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/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.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() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -123,19 +164,9 @@ void main() {
test('channel messages are marked sent immediately', () { test('channel messages are marked sent immediately', () {
final provider = MessagesProvider(); final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage( provider.addSentMessage(
Message( _buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000),
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,
),
); );
provider.markMessageSent('c1', 0, 0); 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', () { test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();

View File

@@ -131,6 +131,108 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001)); 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 { test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider(); final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope( 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

@@ -0,0 +1,185 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('No packets for this filter'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
testWidgets('updates summary and stream for incoming live traffic', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh,
now: () => now,
),
),
);
logs.addAll([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 13.5,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 3)),
direction: PacketDirection.tx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 2)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]);
refresh.value += 1;
await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), 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', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
),
);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('MULTI-HOP'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No packets for this filter'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
_log(
timestamp: now,
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
);
logs.add(
_log(
timestamp: now,
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
);
refresh.value += 1;
await tester.pump();
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

@@ -0,0 +1,143 @@
import 'dart:typed_data';
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/services/contact_route_resolver.dart';
void main() {
test('prefers known repeater route when available', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final routedRepeater = _contact(
name: 'Routed',
type: ContactType.repeater,
seed: 10,
lat: 46.15,
lon: 14.60,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [routedRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.tokens, [
'AA',
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
]);
});
test('falls back to location-only repeaters', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final nearRepeater = _contact(
name: 'Near',
type: ContactType.repeater,
seed: 10,
lat: 46.08,
lon: 14.55,
);
final farRepeater = _contact(
name: 'Far',
type: ContactType.repeater,
seed: 11,
lat: 46.40,
lon: 15.10,
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [nearRepeater, farRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.first.displayName, 'Near');
});
test('for known routes prefers anchor closest to the chain end', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final chainHop = _contact(
name: 'Chain Hop',
type: ContactType.repeater,
seed: 0xAA,
lat: 46.01,
lon: 14.51,
);
final nearAnchor = _contact(
name: 'Near Anchor',
type: ContactType.repeater,
seed: 10,
lat: 46.03,
lon: 14.53,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final farAnchor = _contact(
name: 'Far Anchor',
type: ContactType.repeater,
seed: 11,
lat: 46.18,
lon: 14.68,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [chainHop, nearAnchor, farAnchor],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
});
}
Contact _contact({
required String name,
required ContactType type,
required int seed,
required double lat,
required double lon,
int signedPathLen = -1,
Uint8List? outPath,
}) {
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath ?? Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).round(),
advLon: (lon * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}

View File

@@ -0,0 +1,168 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
group('LiveTrafficSummary', () {
test('uses only the rolling 60-second window', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 61)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.tx,
rawData: [0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.rx,
rawData: [0x01, 0x02],
responseCode: 0x01,
),
], now: now);
expect(snapshot.totalCount, 1);
expect(snapshot.rxCount, 1);
expect(snapshot.txCount, 0);
expect(snapshot.packetsPerMinute, 1);
});
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 12.0,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 15)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
responseCode: 0x88,
snrDb: 6.0,
rssiDbm: -90,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
], now: now);
expect(snapshot.latestRssiDbm, -90);
expect(snapshot.latestSnrDb, 6.0);
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
expect(snapshot.multiHopCount, 2);
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
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));
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
], now: now, clearedAt: clearAt);
expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1));
});
});
}

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/services/message_storage_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'retains path bytes for stored unread message when reception sidecar is missing',
() async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-1',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Unread message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
pathBytes: const [0xAA, 0xBB, 0xCC],
),
},
);
final prefs = await SharedPreferences.getInstance();
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]);
},
);
test('retains embedded reception details when sidecar is missing', () async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-2',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
channelIdx: 2,
pathLen: 3,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Room update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
rssiDbm: -91,
snrDb: 7.25,
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
receivedCopies: 3,
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restored = restoredDetails[message.id];
expect(restored, isNotNull);
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
expect(restored.rssiDbm, -91);
expect(restored.snrDb, 7.25);
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

@@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_history.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart'; import 'package:meshcore_sar_app/services/path_history_service.dart';
@@ -161,15 +162,48 @@ void main() {
expect(selection.mode, PathSelectionMode.flood); expect(selection.mode, PathSelectionMode.flood);
}); });
test('received public byte path is added to history', () async { test(
final service = PathHistoryService(); 'received public byte path is reversed before adding to history',
await service.initialize(); () async {
await service.recordReceivedBytePath('abc123', [0x01, 0x02, 0x03], 3); final service = PathHistoryService();
await service.initialize();
await service.recordReceivedBytePath('abc123', [
0x01,
0x02,
0x03,
0x04,
], 2);
final history = service.historyFor('abc123'); final history = service.historyFor('abc123');
expect(history.directPaths, hasLength(1)); expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.pathBytes, [0x01, 0x02, 0x03]); expect(history.directPaths.single.pathBytes, [0x03, 0x04, 0x01, 0x02]);
expect(history.directPaths.single.hashSize, 3); expect(history.directPaths.single.hashSize, 2);
expect(history.directPaths.single.hopCount, 1); expect(history.directPaths.single.hopCount, 2);
}); expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
test(
'learned paths stay marked as observed after being seen on-air',
() async {
final service = PathHistoryService();
final contact = _buildContact(
seed: 3,
pathBytes: [0xAA, 0xBB],
hopCount: 2,
hashSize: 1,
);
await service.initialize();
await service.recordReceivedBytePath(contact.publicKeyHex, [
0xBB,
0xAA,
], 1);
await service.recordLearnedPath(contact);
final history = service.historyFor(contact.publicKeyHex);
expect(history.directPaths, hasLength(1));
expect(history.directPaths.single.source, PathRecordSource.observed);
},
);
} }

View File

@@ -26,12 +26,12 @@ void main() {
); );
} }
group('ContactGrouping.buildItems', () { group('ContactGrouping.inferGroups', () {
test( test(
'groups prefixed contacts only when at least four share the prefix', 'groups prefixed contacts only when at least four share the prefix',
() { () {
final now = DateTime(2026, 3, 10, 12); 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: 1, name: 'SI-1', lastSeen: now),
buildContact( buildContact(
seed: 2, seed: 2,
@@ -55,20 +55,20 @@ void main() {
), ),
]); ]);
expect(items, hasLength(2)); expect(groups, hasLength(1));
expect(items.first.isGroup, isTrue); expect(groups.first.label, 'SI-');
expect(items.first.group!.label, 'SI-'); expect(groups.first.contacts.map((contact) => contact.displayName), [
expect( 'SI-1',
items.first.group!.contacts.map((contact) => contact.displayName), 'SI-2',
['SI-1', 'SI-2', 'SI-3', 'SI-4'], 'SI-3',
); 'SI-4',
expect(items.last.contact!.displayName, 'OTHER'); ]);
}, },
); );
test('does not group when only three contacts share a prefix', () { test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12); 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: 1, name: 'SI-1', lastSeen: now),
buildContact( buildContact(
seed: 2, seed: 2,
@@ -82,18 +82,12 @@ void main() {
), ),
]); ]);
expect(items, hasLength(3)); expect(groups, isEmpty);
expect(items.every((item) => !item.isGroup), isTrue);
}); });
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 now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([ final groups = ContactGrouping.inferGroups([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(seed: 2, name: 'SI-1', lastSeen: now), buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact( buildContact(
seed: 3, seed: 3,
@@ -110,11 +104,261 @@ void main() {
name: 'SI-4', name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 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(groups, hasLength(2));
expect(items.first.isGroup, isTrue); expect(groups.first.label, 'SI-');
expect(items.last.contact!.displayName, 'Lone'); 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

@@ -116,6 +116,21 @@ void main() {
expect(resolved.matchCount, 2); expect(resolved.matchCount, 2);
}); });
}); });
group('LogRxRouteDecoder.reverseHopBytes', () {
test('reverses path by hop size', () {
final reversed = LogRxRouteDecoder.reverseHopBytes([
0xc2,
0xba,
0x5f,
0xde,
0xaa,
0xbb,
], hashSize: 2);
expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]);
});
});
} }
Contact _contact({required String name, required List<int> keyPrefix}) { Contact _contact({required String name, required List<int> keyPrefix}) {

View File

@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:meshcore_sar_app/utils/trace_node_resolver.dart';
void main() {
test(
'prefers closest local repeater over online fallback for shared prefix',
() {
final localNear = _node(
name: 'Near Local',
publicKey: 'aa1100',
latitude: 46.05,
longitude: 14.50,
);
final localFar = _node(
name: 'Far Local',
publicKey: 'aa2200',
latitude: 46.40,
longitude: 14.90,
);
final online = _node(
name: 'Online',
publicKey: 'aa3300',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [localNear, localFar, online],
localPublicKeys: {localNear.publicKey, localFar.publicKey},
prefixHex: 'aa',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Near Local');
expect(resolved.usedOnlineFallback, isFalse);
expect(resolved.matchCount, 2);
expect(resolved.matchSummary, '2 local matches');
},
);
test('falls back to online node only when local match is missing', () {
final online = _node(
name: 'Online Only',
publicKey: 'bb1100',
latitude: 46.06,
longitude: 14.51,
);
final resolved = TraceNodeResolver.resolveBest(
nodes: [online],
localPublicKeys: const {},
prefixHex: 'bb',
referenceA: const LatLng(46.0, 14.5),
referenceB: const LatLng(46.1, 14.5),
);
expect(resolved.node?.name, 'Online Only');
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({
required String name,
required String publicKey,
required double latitude,
required double longitude,
}) {
return MeshMapNode(
type: 1,
name: name,
publicKey: publicKey,
latitude: latitude,
longitude: longitude,
updatedAtMs: 1,
);
}

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

@@ -0,0 +1,107 @@
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/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/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46562000,
advLon: 14950000,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpTile(WidgetTester tester, Contact contact) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ContactTile(contact: contact)),
),
),
);
}
testWidgets('shows trace action for non-channel contacts', (tester) async {
await pumpTile(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
);
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsOneWidget);
});
testWidgets('does not show trace action for channels', (tester) async {
await pumpTile(
tester,
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
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('7'), findsOneWidget);
expect(find.text('3'), 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);
});
} }