mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 17:30:28 +00:00
Compare commits
45 Commits
v2026.0312
...
v2026.0315
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
e502bc23b7 | ||
|
|
62bf33c9d1 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 |
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal 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`
|
||||||
@@ -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 = 112;
|
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 = 112;
|
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 = 112;
|
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 = 112;
|
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 = 112;
|
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 = 112;
|
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;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>112</string>
|
<string>116</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -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}',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ class SavedContactGroup {
|
|||||||
final String label;
|
final String label;
|
||||||
final String query;
|
final String query;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
final List<String>? matchPrefixes;
|
||||||
|
final bool isAutoGroup;
|
||||||
|
|
||||||
const SavedContactGroup({
|
const SavedContactGroup({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -11,6 +13,8 @@ class SavedContactGroup {
|
|||||||
required this.label,
|
required this.label,
|
||||||
required this.query,
|
required this.query,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.matchPrefixes,
|
||||||
|
this.isAutoGroup = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
SavedContactGroup copyWith({
|
SavedContactGroup copyWith({
|
||||||
@@ -19,6 +23,8 @@ class SavedContactGroup {
|
|||||||
String? label,
|
String? label,
|
||||||
String? query,
|
String? query,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool? isAutoGroup,
|
||||||
}) {
|
}) {
|
||||||
return SavedContactGroup(
|
return SavedContactGroup(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -26,6 +32,8 @@ class SavedContactGroup {
|
|||||||
label: label ?? this.label,
|
label: label ?? this.label,
|
||||||
query: query ?? this.query,
|
query: query ?? this.query,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ enum ConnectionMode {
|
|||||||
/// Direct BLE connection to MeshCore device (default)
|
/// Direct BLE connection to MeshCore device (default)
|
||||||
ble,
|
ble,
|
||||||
|
|
||||||
/// Act as SSE server - share BLE device with multiple clients
|
|
||||||
sseServer,
|
|
||||||
|
|
||||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
tcp,
|
tcp,
|
||||||
}
|
}
|
||||||
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share Device (Server)';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
}
|
}
|
||||||
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share BLE device with multiple clients over network';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
}
|
}
|
||||||
@@ -66,6 +59,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 +111,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 +242,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 +286,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
/// SSE Server Configuration Model
|
|
||||||
///
|
|
||||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
|
||||||
/// multiple app instances to share a single MeshCore BLE device.
|
|
||||||
class SseServerConfig {
|
|
||||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
|
||||||
final String host;
|
|
||||||
|
|
||||||
/// Server port (default: 12929)
|
|
||||||
final int port;
|
|
||||||
|
|
||||||
/// Whether the SSE server is enabled
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
/// Optional authentication token for basic security
|
|
||||||
/// Clients must include this token in Authorization header
|
|
||||||
final String? authToken;
|
|
||||||
|
|
||||||
const SseServerConfig({
|
|
||||||
this.host = '0.0.0.0',
|
|
||||||
this.port = 12929,
|
|
||||||
this.enabled = false,
|
|
||||||
this.authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Create a copy with updated fields
|
|
||||||
SseServerConfig copyWith({
|
|
||||||
String? host,
|
|
||||||
int? port,
|
|
||||||
bool? enabled,
|
|
||||||
String? authToken,
|
|
||||||
}) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: host ?? this.host,
|
|
||||||
port: port ?? this.port,
|
|
||||||
enabled: enabled ?? this.enabled,
|
|
||||||
authToken: authToken ?? this.authToken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get server URL for clients to connect to
|
|
||||||
String getServerUrl({String? ipAddress}) {
|
|
||||||
final ip = ipAddress ?? host;
|
|
||||||
return 'http://$ip:$port';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to JSON for persistence
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'enabled': enabled,
|
|
||||||
'authToken': authToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: json['host'] as String? ?? '0.0.0.0',
|
|
||||||
port: json['port'] as int? ?? 12929,
|
|
||||||
enabled: json['enabled'] as bool? ?? false,
|
|
||||||
authToken: json['authToken'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -12,14 +13,40 @@ import '../utils/key_comparison.dart';
|
|||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
final Uint8List publicKey;
|
final Uint8List publicKey;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
final String? advName;
|
||||||
|
final int? typeValue;
|
||||||
|
final int? flags;
|
||||||
|
final int? lastAdvert;
|
||||||
|
final int? advLat;
|
||||||
|
final int? advLon;
|
||||||
final int? signedEncodedPathLen;
|
final int? signedEncodedPathLen;
|
||||||
final Uint8List? paddedPathBytes;
|
final Uint8List? paddedPathBytes;
|
||||||
|
final int? rxRssiDbm;
|
||||||
|
final int? rxSnrRaw;
|
||||||
|
final int? repeaterBatteryMv;
|
||||||
|
final int? repeaterQueueLen;
|
||||||
|
final int? repeaterLastRssi;
|
||||||
|
final int? repeaterLastSnrRaw;
|
||||||
|
final int? repeaterUptimeSecs;
|
||||||
|
|
||||||
const PendingAdvert({
|
const PendingAdvert({
|
||||||
required this.publicKey,
|
required this.publicKey,
|
||||||
required this.receivedAt,
|
required this.receivedAt,
|
||||||
|
this.advName,
|
||||||
|
this.typeValue,
|
||||||
|
this.flags,
|
||||||
|
this.lastAdvert,
|
||||||
|
this.advLat,
|
||||||
|
this.advLon,
|
||||||
this.signedEncodedPathLen,
|
this.signedEncodedPathLen,
|
||||||
this.paddedPathBytes,
|
this.paddedPathBytes,
|
||||||
|
this.rxRssiDbm,
|
||||||
|
this.rxSnrRaw,
|
||||||
|
this.repeaterBatteryMv,
|
||||||
|
this.repeaterQueueLen,
|
||||||
|
this.repeaterLastRssi,
|
||||||
|
this.repeaterLastSnrRaw,
|
||||||
|
this.repeaterUptimeSecs,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get publicKeyHex =>
|
String get publicKeyHex =>
|
||||||
@@ -33,16 +60,55 @@ class PendingAdvert {
|
|||||||
PendingAdvert copyWith({
|
PendingAdvert copyWith({
|
||||||
Uint8List? publicKey,
|
Uint8List? publicKey,
|
||||||
DateTime? receivedAt,
|
DateTime? receivedAt,
|
||||||
|
String? advName,
|
||||||
|
int? typeValue,
|
||||||
|
int? flags,
|
||||||
|
int? lastAdvert,
|
||||||
|
int? advLat,
|
||||||
|
int? advLon,
|
||||||
int? signedEncodedPathLen,
|
int? signedEncodedPathLen,
|
||||||
Uint8List? paddedPathBytes,
|
Uint8List? paddedPathBytes,
|
||||||
|
int? rxRssiDbm,
|
||||||
|
int? rxSnrRaw,
|
||||||
|
int? repeaterBatteryMv,
|
||||||
|
int? repeaterQueueLen,
|
||||||
|
int? repeaterLastRssi,
|
||||||
|
int? repeaterLastSnrRaw,
|
||||||
|
int? repeaterUptimeSecs,
|
||||||
}) {
|
}) {
|
||||||
return PendingAdvert(
|
return PendingAdvert(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
receivedAt: receivedAt ?? this.receivedAt,
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
advName: advName ?? this.advName,
|
||||||
|
typeValue: typeValue ?? this.typeValue,
|
||||||
|
flags: flags ?? this.flags,
|
||||||
|
lastAdvert: lastAdvert ?? this.lastAdvert,
|
||||||
|
advLat: advLat ?? this.advLat,
|
||||||
|
advLon: advLon ?? this.advLon,
|
||||||
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
||||||
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
||||||
|
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
|
||||||
|
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
|
||||||
|
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
|
||||||
|
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
|
||||||
|
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
|
||||||
|
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
|
||||||
|
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double? get repeaterBatteryPercent {
|
||||||
|
if (repeaterBatteryMv == null) return null;
|
||||||
|
final voltage = repeaterBatteryMv! / 1000.0;
|
||||||
|
if (voltage <= 3.0) return 0.0;
|
||||||
|
if (voltage >= 4.2) return 100.0;
|
||||||
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double? get repeaterLastSnr =>
|
||||||
|
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
|
||||||
|
|
||||||
|
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RetainedRoute {
|
class _RetainedRoute {
|
||||||
@@ -58,11 +124,16 @@ class _RetainedRoute {
|
|||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
static const double _firstHopFallbackOffsetMeters = 100.0;
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
|
static const String autoGroupIdPrefix = 'auto_group_';
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _isPersisting = false;
|
||||||
|
bool _persistRequested = false;
|
||||||
|
bool _isPersistingPendingAdverts = false;
|
||||||
|
bool _persistPendingAdvertsRequested = false;
|
||||||
|
|
||||||
// Add default public channel on initialization
|
// Add default public channel on initialization
|
||||||
ContactsProvider() {
|
ContactsProvider() {
|
||||||
@@ -82,6 +153,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -98,6 +170,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(storedPendingAdverts);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -130,6 +203,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -146,6 +220,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(
|
||||||
|
storedPendingAdverts,
|
||||||
|
devicePublicKey: devicePublicKey,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -201,19 +279,38 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist contacts to storage (async, non-blocking)
|
/// Persist contacts to storage (async, non-blocking, coalescing).
|
||||||
Future<void> _persistContacts() async {
|
Future<void> _persistContacts() async {
|
||||||
|
_persistRequested = true;
|
||||||
|
if (_isPersisting) return;
|
||||||
|
_isPersisting = true;
|
||||||
try {
|
try {
|
||||||
// Don't persist the public channel pseudo-contact (all zeros key)
|
while (_persistRequested) {
|
||||||
const publicChannelKey =
|
_persistRequested = false;
|
||||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
final contactsToSave = _contacts.entries
|
}
|
||||||
.where((entry) => entry.key != publicChannelKey)
|
|
||||||
.map((entry) => entry.value)
|
|
||||||
.toList();
|
|
||||||
await _storageService.saveContacts(contactsToSave);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersisting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistPendingAdverts() async {
|
||||||
|
_persistPendingAdvertsRequested = true;
|
||||||
|
if (_isPersistingPendingAdverts) return;
|
||||||
|
_isPersistingPendingAdverts = true;
|
||||||
|
try {
|
||||||
|
while (_persistPendingAdvertsRequested) {
|
||||||
|
_persistPendingAdvertsRequested = false;
|
||||||
|
await _storageService.savePendingAdverts(
|
||||||
|
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersistingPendingAdverts = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +322,28 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_pendingAdverts.values.toList()
|
_pendingAdverts.values.toList()
|
||||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
|
||||||
|
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
return _pendingAdverts[keyHex];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
|
||||||
|
final advert = pendingAdvertByKey(publicKey);
|
||||||
|
if (advert == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasName = advert.advName?.trim().isNotEmpty ?? false;
|
||||||
|
final hasType = advert.typeValue != null && advert.typeValue != 0;
|
||||||
|
final hasLocation =
|
||||||
|
advert.advLat != null &&
|
||||||
|
advert.advLon != null &&
|
||||||
|
(advert.advLat != 0 || advert.advLon != 0);
|
||||||
|
return !(hasName && hasType && hasLocation);
|
||||||
|
}
|
||||||
|
|
||||||
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
||||||
return savedContactGroups
|
return savedContactGroups
|
||||||
.where((group) => group.sectionKey == sectionKey)
|
.where((group) => group.sectionKey == sectionKey)
|
||||||
@@ -248,6 +367,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
String sectionKey,
|
String sectionKey,
|
||||||
String query, {
|
String query, {
|
||||||
String? label,
|
String? label,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool isAutoGroup = false,
|
||||||
}) async {
|
}) async {
|
||||||
final normalizedQuery = _normalizeGroupQuery(query);
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
if (normalizedQuery.isEmpty ||
|
if (normalizedQuery.isEmpty ||
|
||||||
@@ -262,6 +383,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
label: (label ?? query).trim(),
|
label: (label ?? query).trim(),
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
|
matchPrefixes: matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,6 +422,18 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAutoGroupsForSection(
|
||||||
|
String sectionKey,
|
||||||
|
List<SavedContactGroup> groups,
|
||||||
|
) async {
|
||||||
|
_savedContactGroups.removeWhere(
|
||||||
|
(group) => group.sectionKey == sectionKey && group.isAutoGroup,
|
||||||
|
);
|
||||||
|
_savedContactGroups.addAll(groups);
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistSavedGroups() async {
|
Future<void> _persistSavedGroups() async {
|
||||||
try {
|
try {
|
||||||
await _storageService.saveContactGroups(_savedContactGroups);
|
await _storageService.saveContactGroups(_savedContactGroups);
|
||||||
@@ -315,6 +450,9 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
List<Contact> get repeaters =>
|
List<Contact> get repeaters =>
|
||||||
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
|
List<Contact> get sensorContacts =>
|
||||||
|
contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
List<Contact> get rooms =>
|
List<Contact> get rooms =>
|
||||||
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
@@ -423,6 +561,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||||
);
|
);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
debugPrint(' 🔔 notifyListeners() called');
|
debugPrint(' 🔔 notifyListeners() called');
|
||||||
}
|
}
|
||||||
@@ -454,6 +593,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,6 +654,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
var updatedContact = incomingContact.copyWith(
|
var updatedContact = incomingContact.copyWith(
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
nameOverride: existingContact.nameOverride,
|
||||||
advertHistory: existingContact.advertHistory,
|
advertHistory: existingContact.advertHistory,
|
||||||
telemetry: mergedTelemetry,
|
telemetry: mergedTelemetry,
|
||||||
outPathLen:
|
outPathLen:
|
||||||
@@ -889,7 +1030,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
for (final contact in contacts) {
|
for (final contact in _contacts.values) {
|
||||||
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
if (contact.publicKeyHex.startsWith(prefixHex)) {
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
@@ -1023,40 +1164,153 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
void setContactNameOverride(String publicKeyHex, String? overrideName) {
|
||||||
/// Excludes self key and existing contacts.
|
final contact = _contacts[publicKeyHex];
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
if (contact == null) {
|
||||||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final normalizedOverride = overrideName?.trim();
|
||||||
|
final nextOverride =
|
||||||
|
(normalizedOverride == null || normalizedOverride.isEmpty)
|
||||||
|
? null
|
||||||
|
: normalizedOverride;
|
||||||
|
if (contact.nameOverride == nextOverride) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_contacts[publicKeyHex] = contact.copyWith(nameOverride: nextOverride);
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
|
/// Excludes only self key; known contacts still keep a discovery entry.
|
||||||
|
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final keyHex = publicKey
|
final keyHex = publicKey
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join('');
|
.join('');
|
||||||
if (_contacts.containsKey(keyHex)) {
|
|
||||||
_pendingAdverts.remove(keyHex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final existing = _pendingAdverts[keyHex];
|
final existing = _pendingAdverts[keyHex];
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
_pendingAdverts[keyHex] = PendingAdvert(
|
_pendingAdverts[keyHex] = PendingAdvert(
|
||||||
publicKey: Uint8List.fromList(publicKey),
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
receivedAt: now,
|
receivedAt: now,
|
||||||
);
|
);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addOrUpdatePendingAdvertContact(
|
||||||
|
Contact contact, {
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = contact.publicKeyHex;
|
||||||
|
final route = ContactRouteCodec.fromContact(contact);
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
final now = DateTime.now();
|
||||||
|
final updated =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(contact.publicKey),
|
||||||
|
receivedAt: now,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: now,
|
||||||
|
advName: contact.advName.trim().isEmpty ? null : contact.advName,
|
||||||
|
typeValue: contact.type.value,
|
||||||
|
flags: contact.flags,
|
||||||
|
lastAdvert: contact.lastAdvert,
|
||||||
|
advLat: contact.advLat,
|
||||||
|
advLon: contact.advLon,
|
||||||
|
signedEncodedPathLen:
|
||||||
|
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: route?.paddedPathBytes == null
|
||||||
|
? existing?.paddedPathBytes
|
||||||
|
: Uint8List.fromList(route!.paddedPathBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
_pendingAdverts[keyHex] = updated;
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return existing == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool addOrUpdatePendingAdvertMetadata({
|
||||||
|
required Uint8List publicKey,
|
||||||
|
required int typeValue,
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
int? flags,
|
||||||
|
String? advName,
|
||||||
|
int? lastAdvert,
|
||||||
|
int? advLat,
|
||||||
|
int? advLon,
|
||||||
|
int? signedEncodedPathLen,
|
||||||
|
Uint8List? paddedPathBytes,
|
||||||
|
int? rxRssiDbm,
|
||||||
|
int? rxSnrRaw,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
final nextReceivedAt = receivedAt ?? DateTime.now();
|
||||||
|
_pendingAdverts[keyHex] =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
|
receivedAt: nextReceivedAt,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: nextReceivedAt,
|
||||||
|
typeValue: typeValue,
|
||||||
|
advName: advName?.trim().isNotEmpty == true
|
||||||
|
? advName!.trim()
|
||||||
|
: existing?.advName,
|
||||||
|
flags: flags ?? existing?.flags,
|
||||||
|
lastAdvert: lastAdvert ?? existing?.lastAdvert,
|
||||||
|
advLat: advLat ?? existing?.advLat,
|
||||||
|
advLon: advLon ?? existing?.advLon,
|
||||||
|
signedEncodedPathLen:
|
||||||
|
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: paddedPathBytes == null
|
||||||
|
? existing?.paddedPathBytes
|
||||||
|
: Uint8List.fromList(paddedPathBytes),
|
||||||
|
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
|
||||||
|
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
|
||||||
|
);
|
||||||
|
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return existing == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find contact by name
|
/// Find contact by name
|
||||||
Contact? findContactByName(String name) {
|
Contact? findContactByName(String name) {
|
||||||
return contacts.firstWhere(
|
for (final contact in _contacts.values) {
|
||||||
(c) => c.advName == name,
|
if (contact.advName == name) return contact;
|
||||||
orElse: () => contacts.first,
|
}
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get contacts with low battery
|
/// Get contacts with low battery
|
||||||
@@ -1105,7 +1359,9 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
void clearContacts() {
|
void clearContacts() {
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
|
_ensurePublicChannelExists();
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1384,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_contacts.remove(publicKeyHex);
|
_contacts.remove(publicKeyHex);
|
||||||
_pendingAdverts.remove(publicKeyHex);
|
_pendingAdverts.remove(publicKeyHex);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,11 +1393,145 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return await _storageService.getStorageStats();
|
return await _storageService.getStorageStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> persistNow() async {
|
||||||
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
if (_pendingAdverts.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
await _storageService.clearPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePendingAdvertStatusByPrefix(
|
||||||
|
Uint8List publicKeyPrefix, {
|
||||||
|
int? batteryMv,
|
||||||
|
int? queueLen,
|
||||||
|
int? lastRssi,
|
||||||
|
int? lastSnrRaw,
|
||||||
|
int? uptimeSecs,
|
||||||
|
}) {
|
||||||
|
final prefixHex = publicKeyPrefix
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final match = _pendingAdverts.entries.where(
|
||||||
|
(entry) => entry.key.startsWith(prefixHex),
|
||||||
|
);
|
||||||
|
if (match.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final key = match.first.key;
|
||||||
|
final existing = _pendingAdverts[key]!;
|
||||||
|
_pendingAdverts[key] = existing.copyWith(
|
||||||
|
repeaterBatteryMv: batteryMv,
|
||||||
|
repeaterQueueLen: queueLen,
|
||||||
|
repeaterLastRssi: lastRssi,
|
||||||
|
repeaterLastSnrRaw: lastSnrRaw,
|
||||||
|
repeaterUptimeSecs: uptimeSecs,
|
||||||
|
);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _contactsForStorage() {
|
||||||
|
// Don't persist the public channel pseudo-contact (all zeros key)
|
||||||
|
const publicChannelKey =
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||||
|
return _contacts.entries
|
||||||
|
.where((entry) => entry.key != publicChannelKey)
|
||||||
|
.map((entry) => entry.value)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restorePendingAdverts(
|
||||||
|
List<Map<String, dynamic>> storedPendingAdverts, {
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
for (final json in storedPendingAdverts) {
|
||||||
|
final advert = _pendingAdvertFromJson(json);
|
||||||
|
if (advert == null) continue;
|
||||||
|
if (devicePublicKey != null &&
|
||||||
|
advert.publicKey.matches(devicePublicKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_contacts.containsKey(advert.publicKeyHex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_pendingAdverts[advert.publicKeyHex] = advert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||||||
|
return {
|
||||||
|
'publicKey': base64Encode(advert.publicKey),
|
||||||
|
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
|
||||||
|
'advName': advert.advName,
|
||||||
|
'typeValue': advert.typeValue,
|
||||||
|
'flags': advert.flags,
|
||||||
|
'lastAdvert': advert.lastAdvert,
|
||||||
|
'advLat': advert.advLat,
|
||||||
|
'advLon': advert.advLon,
|
||||||
|
'signedEncodedPathLen': advert.signedEncodedPathLen,
|
||||||
|
'paddedPathBytes': advert.paddedPathBytes == null
|
||||||
|
? null
|
||||||
|
: base64Encode(advert.paddedPathBytes!),
|
||||||
|
'rxRssiDbm': advert.rxRssiDbm,
|
||||||
|
'rxSnrRaw': advert.rxSnrRaw,
|
||||||
|
'repeaterBatteryMv': advert.repeaterBatteryMv,
|
||||||
|
'repeaterQueueLen': advert.repeaterQueueLen,
|
||||||
|
'repeaterLastRssi': advert.repeaterLastRssi,
|
||||||
|
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
|
||||||
|
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(
|
||||||
|
base64Decode(json['publicKey'] as String),
|
||||||
|
),
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['receivedAtMillis'] as int,
|
||||||
|
),
|
||||||
|
advName: json['advName'] as String?,
|
||||||
|
typeValue: json['typeValue'] as int?,
|
||||||
|
flags: json['flags'] as int?,
|
||||||
|
lastAdvert: json['lastAdvert'] as int?,
|
||||||
|
advLat: json['advLat'] as int?,
|
||||||
|
advLon: json['advLon'] as int?,
|
||||||
|
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
|
||||||
|
paddedPathBytes: json['paddedPathBytes'] == null
|
||||||
|
? null
|
||||||
|
: Uint8List.fromList(
|
||||||
|
base64Decode(json['paddedPathBytes'] as String),
|
||||||
|
),
|
||||||
|
rxRssiDbm: json['rxRssiDbm'] as int?,
|
||||||
|
rxSnrRaw: json['rxSnrRaw'] as int?,
|
||||||
|
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
|
||||||
|
repeaterQueueLen: json['repeaterQueueLen'] as int?,
|
||||||
|
repeaterLastRssi: json['repeaterLastRssi'] as int?,
|
||||||
|
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
|
||||||
|
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get contact count by type
|
/// Get contact count by type
|
||||||
Map<String, int> get contactCounts {
|
Map<String, int> get contactCounts {
|
||||||
return {
|
return {
|
||||||
'chat': chatContacts.length,
|
'chat': chatContacts.length,
|
||||||
'repeater': repeaters.length,
|
'repeater': repeaters.length,
|
||||||
|
'sensor': sensorContacts.length,
|
||||||
'room': rooms.length,
|
'room': rooms.length,
|
||||||
'total': contacts.length,
|
'total': contacts.length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {};
|
||||||
@@ -125,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();
|
||||||
@@ -154,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, {
|
||||||
@@ -244,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;
|
||||||
|
|
||||||
@@ -298,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);
|
||||||
@@ -310,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) {
|
||||||
@@ -357,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,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;
|
||||||
@@ -523,6 +553,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final incomingPathBytes = receptionDetailsSnapshot?.pathBytes;
|
||||||
|
if (incomingPathBytes != null &&
|
||||||
|
incomingPathBytes.isNotEmpty &&
|
||||||
|
finalMessage.pathBytes == null) {
|
||||||
|
finalMessage = finalMessage.copyWith(
|
||||||
|
pathBytes: Uint8List.fromList(incomingPathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Debug: Check if message is SAR
|
// Debug: Check if message is SAR
|
||||||
if (message.text.startsWith('S:')) {
|
if (message.text.startsWith('S:')) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -537,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,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)
|
||||||
@@ -598,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
|
||||||
@@ -651,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
|
||||||
}
|
}
|
||||||
@@ -664,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,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,
|
||||||
@@ -825,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,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)
|
||||||
@@ -866,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);
|
||||||
@@ -886,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;
|
||||||
@@ -984,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,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);
|
||||||
}
|
}
|
||||||
@@ -1006,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1028,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,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}',
|
||||||
);
|
);
|
||||||
@@ -1238,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}"');
|
||||||
@@ -1457,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');
|
||||||
@@ -1505,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();
|
||||||
@@ -1726,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(),
|
||||||
@@ -1880,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');
|
||||||
@@ -1919,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();
|
||||||
@@ -1984,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);
|
||||||
@@ -2082,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,
|
||||||
@@ -2110,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();
|
||||||
@@ -2168,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();
|
||||||
@@ -2183,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();
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
static const String _fieldSpanKey = 'sensor_field_spans';
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
|
static const String _metricLabelKey = 'sensor_metric_labels';
|
||||||
|
static const String _metricOrderKey = 'sensor_metric_order';
|
||||||
|
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
||||||
|
static const List<int> supportedAutoRefreshIntervals = <int>[0, 1, 5, 15];
|
||||||
static const Set<String> _defaultVisibleFields = <String>{
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
'voltage',
|
'voltage',
|
||||||
'battery',
|
'battery',
|
||||||
@@ -23,6 +27,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
'pressure',
|
'pressure',
|
||||||
'gps',
|
'gps',
|
||||||
};
|
};
|
||||||
|
static const List<String> _defaultMetricOrder = <String>[
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'gps',
|
||||||
|
];
|
||||||
|
|
||||||
final List<String> _watchedSensorKeys = <String>[];
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
final Map<String, SensorRefreshState> _refreshStates =
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
@@ -32,8 +44,15 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
<String, Set<String>>{};
|
<String, Set<String>>{};
|
||||||
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
<String, Map<String, int>>{};
|
<String, Map<String, int>>{};
|
||||||
|
final Map<String, Map<String, String>> _metricLabelsBySensor =
|
||||||
|
<String, Map<String, String>>{};
|
||||||
|
final Map<String, List<String>> _metricOrderBySensor =
|
||||||
|
<String, List<String>>{};
|
||||||
|
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
|
||||||
|
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _isRefreshingAll = false;
|
bool _isRefreshingAll = false;
|
||||||
|
bool _isRunningAutoRefreshTick = false;
|
||||||
|
|
||||||
SensorsProvider() {
|
SensorsProvider() {
|
||||||
unawaited(_loadWatchedSensors());
|
unawaited(_loadWatchedSensors());
|
||||||
@@ -52,11 +71,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||||
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
||||||
|
final storedLabelsJson = prefs.getString(_metricLabelKey);
|
||||||
|
final storedOrderJson = prefs.getString(_metricOrderKey);
|
||||||
|
final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey);
|
||||||
_watchedSensorKeys
|
_watchedSensorKeys
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(stored);
|
..addAll(stored);
|
||||||
_visibleFieldsBySensor.clear();
|
_visibleFieldsBySensor.clear();
|
||||||
_fieldSpansBySensor.clear();
|
_fieldSpansBySensor.clear();
|
||||||
|
_metricLabelsBySensor.clear();
|
||||||
|
_metricOrderBySensor.clear();
|
||||||
|
_autoRefreshMinutesBySensor.clear();
|
||||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
@@ -72,12 +97,47 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
.map((key, value) => MapEntry(key, value as int));
|
.map((key, value) => MapEntry(key, value as int));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedLabelsJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricLabelsBySensor[entry.key] =
|
||||||
|
(entry.value as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedOrderJson != null && storedOrderJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedOrderJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricOrderBySensor[entry.key] = (entry.value as List<dynamic>)
|
||||||
|
.cast<String>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) {
|
||||||
|
final decoded =
|
||||||
|
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final minutes = (entry.value as num).toInt();
|
||||||
|
if (minutes > 0) {
|
||||||
|
_autoRefreshMinutesBySensor[entry.key] = minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_autoRefreshMinutesBySensor.removeWhere(
|
||||||
|
(key, _) => !_watchedSensorKeys.contains(key),
|
||||||
|
);
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
_visibleFieldsBySensor.putIfAbsent(
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
key,
|
key,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
|
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
|
||||||
|
_metricOrderBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading watched sensors: $e');
|
debugPrint('Error loading watched sensors: $e');
|
||||||
@@ -118,6 +178,36 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricLabels() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric labels: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricOrder() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric order: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistAutoRefreshMinutes() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_autoRefreshMinutesKey,
|
||||||
|
jsonEncode(_autoRefreshMinutesBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor auto refresh minutes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
);
|
);
|
||||||
@@ -131,6 +221,78 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
return span == 2 ? 2 : 1;
|
return span == 2 ? 2 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> metricOrderFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final available = availableFieldKeys.toList();
|
||||||
|
final availableSet = available.toSet();
|
||||||
|
final ordered = <String>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
final stored =
|
||||||
|
_metricOrderBySensor[publicKeyHex] ??
|
||||||
|
List<String>.from(_defaultMetricOrder);
|
||||||
|
|
||||||
|
for (final fieldKey in stored) {
|
||||||
|
if (availableSet.contains(fieldKey) && seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final fieldKey in available) {
|
||||||
|
if (seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List<String>.unmodifiable(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> labelOverridesFor(String publicKeyHex) =>
|
||||||
|
Map<String, String>.unmodifiable(
|
||||||
|
_metricLabelsBySensor[publicKeyHex] ?? const <String, String>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
String? labelOverrideFor(String publicKeyHex, String fieldKey) =>
|
||||||
|
_metricLabelsBySensor[publicKeyHex]?[fieldKey];
|
||||||
|
|
||||||
|
int autoRefreshMinutesFor(String publicKeyHex) =>
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
||||||
|
|
||||||
|
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
||||||
|
final normalizedMinutes = minutes <= 0 ? 0 : minutes;
|
||||||
|
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
||||||
|
if (currentMinutes == normalizedMinutes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMinutes == 0) {
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
} else {
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
|
||||||
|
}
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = <String>[];
|
||||||
|
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
final minutes = autoRefreshMinutesFor(key);
|
||||||
|
if (minutes <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[key];
|
||||||
|
if (lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
|
||||||
|
dueKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<String>.unmodifiable(dueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleMetric(
|
Future<void> toggleMetric(
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
String fieldKey,
|
String fieldKey,
|
||||||
@@ -140,8 +302,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
|
final metricOrder = _metricOrderBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
|
var shouldPersistOrder = false;
|
||||||
if (visible) {
|
if (visible) {
|
||||||
visibleFields.add(fieldKey);
|
visibleFields.add(fieldKey);
|
||||||
|
if (!metricOrder.contains(fieldKey)) {
|
||||||
|
metricOrder.add(fieldKey);
|
||||||
|
shouldPersistOrder = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
return;
|
return;
|
||||||
@@ -149,6 +320,9 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
visibleFields.remove(fieldKey);
|
visibleFields.remove(fieldKey);
|
||||||
}
|
}
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
|
if (shouldPersistOrder) {
|
||||||
|
await _persistMetricOrder();
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +340,62 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMetricLabel(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
String? label,
|
||||||
|
) async {
|
||||||
|
final sensorLabels = _metricLabelsBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => <String, String>{},
|
||||||
|
);
|
||||||
|
final trimmed = label?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) {
|
||||||
|
sensorLabels.remove(fieldKey);
|
||||||
|
} else {
|
||||||
|
sensorLabels[fieldKey] = trimmed;
|
||||||
|
}
|
||||||
|
if (sensorLabels.isEmpty) {
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
}
|
||||||
|
await _persistMetricLabels();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> moveMetric(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required List<String> availableFieldKeys,
|
||||||
|
required int oldIndex,
|
||||||
|
required int newIndex,
|
||||||
|
}) async {
|
||||||
|
if (oldIndex < 0 ||
|
||||||
|
newIndex < 0 ||
|
||||||
|
oldIndex >= availableFieldKeys.length ||
|
||||||
|
newIndex >= availableFieldKeys.length ||
|
||||||
|
oldIndex == newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reordered = List<String>.from(
|
||||||
|
metricOrderFor(publicKeyHex, availableFieldKeys),
|
||||||
|
);
|
||||||
|
final fieldKey = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, fieldKey);
|
||||||
|
|
||||||
|
final storedTail =
|
||||||
|
(_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where(
|
||||||
|
(key) => !reordered.contains(key),
|
||||||
|
);
|
||||||
|
_metricOrderBySensor[publicKeyHex] = <String>[...reordered, ...storedTail];
|
||||||
|
await _persistMetricOrder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool isWatched(String publicKeyHex) =>
|
bool isWatched(String publicKeyHex) =>
|
||||||
_watchedSensorKeys.contains(publicKeyHex);
|
_watchedSensorKeys.contains(publicKeyHex);
|
||||||
|
|
||||||
Future<void> addSensor(Contact contact) async {
|
Future<void> addSensor(Contact contact) async {
|
||||||
if (!contact.isChat && !contact.isRepeater) {
|
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||||
@@ -183,8 +408,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
_defaultVisibleFields,
|
_defaultVisibleFields,
|
||||||
);
|
);
|
||||||
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
|
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||||
|
_metricOrderBySensor[contact.publicKeyHex] = List<String>.from(
|
||||||
|
_defaultMetricOrder,
|
||||||
|
);
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,9 +425,16 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
_refreshStateUpdatedAt.remove(publicKeyHex);
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
_visibleFieldsBySensor.remove(publicKeyHex);
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
_fieldSpansBySensor.remove(publicKeyHex);
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
_metricOrderBySensor.remove(publicKeyHex);
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +442,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final candidates = <Contact>[
|
final candidates = <Contact>[
|
||||||
...contactsProvider.chatContacts,
|
...contactsProvider.chatContacts,
|
||||||
...contactsProvider.repeaters,
|
...contactsProvider.repeaters,
|
||||||
|
...contactsProvider.sensorContacts,
|
||||||
];
|
];
|
||||||
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||||
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
@@ -239,7 +478,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
required String publicKeyHex,
|
required String publicKeyHex,
|
||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
required ConnectionProvider connectionProvider,
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? requestedAt,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
|
||||||
|
|
||||||
Contact? contact;
|
Contact? contact;
|
||||||
for (final entry in contactsProvider.contacts) {
|
for (final entry in contactsProvider.contacts) {
|
||||||
if (entry.publicKeyHex == publicKeyHex) {
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
@@ -266,6 +512,38 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshDueSensors({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected ||
|
||||||
|
_isRefreshingAll ||
|
||||||
|
_isRunningAutoRefreshTick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
|
||||||
|
if (dueKeys.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunningAutoRefreshTick = true;
|
||||||
|
try {
|
||||||
|
for (final key in dueKeys) {
|
||||||
|
await refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
requestedAt: refreshTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isRunningAutoRefreshTick = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clearExpiredRefreshStates({DateTime? now}) {
|
void clearExpiredRefreshStates({DateTime? now}) {
|
||||||
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||||
final keysToClear = <String>[];
|
final keysToClear = <String>[];
|
||||||
|
|||||||
259
lib/screens/add_contact_screen.dart
Normal file
259
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
|
||||||
|
class AddContactScreen extends StatefulWidget {
|
||||||
|
const AddContactScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactScreenState extends State<AddContactScreen> {
|
||||||
|
final TextEditingController _advertController = TextEditingController();
|
||||||
|
bool _isImporting = false;
|
||||||
|
bool _importSucceeded = false;
|
||||||
|
String? _validationError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadClipboardIfPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_advertController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadClipboardIfPresent() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text?.trim();
|
||||||
|
if (!mounted || text == null || text.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_normalizeAdvertText(text) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_advertController.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizeAdvertText(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = trimmed;
|
||||||
|
if (normalized.startsWith('meshcore://')) {
|
||||||
|
normalized = normalized.substring('meshcore://'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
|
||||||
|
if (!isHex || normalized.length.isOdd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final bytes = <int>[];
|
||||||
|
for (var i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteFromClipboard() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text;
|
||||||
|
if (text == null || text.trim().isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_advertController.text = text.trim();
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importContact() async {
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
if (normalized == null) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertBytes = _hexToBytes(normalized);
|
||||||
|
if (advertBytes.length < 98) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Advert is too short. Expected exported contact data.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
await connectionProvider.importContactAdvert(advertBytes);
|
||||||
|
final importError = connectionProvider.error;
|
||||||
|
|
||||||
|
if (importError == null) {
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importError != null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(importError)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Contact imported')));
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Add Contact')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Import an exported contact advert, like meshcore-open.',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Paste a `meshcore://...` link or raw hex advert from the clipboard.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _advertController,
|
||||||
|
minLines: 4,
|
||||||
|
maxLines: 8,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_validationError != null || _importSucceeded) {
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Contact advert',
|
||||||
|
hintText: 'meshcore://...',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _validationError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (normalized != null)
|
||||||
|
Text(
|
||||||
|
'Advert size: ${normalized.length ~/ 2} bytes',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||||
|
icon: const Icon(Icons.content_paste_go_outlined),
|
||||||
|
label: const Text('Paste'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: (_isImporting || _importSucceeded)
|
||||||
|
? null
|
||||||
|
: _importContact,
|
||||||
|
icon: _isImporting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: _importSucceeded
|
||||||
|
? const Icon(Icons.check_circle_outline)
|
||||||
|
: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
|
label: Text(
|
||||||
|
_isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: _importSucceeded
|
||||||
|
? 'Added'
|
||||||
|
: 'Add Contact',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_importSucceeded) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Contact added. Paste or edit another advert to import again.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/channels_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -14,6 +21,11 @@ class DeviceConfigScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||||
|
static const int _bulkDeleteBatchSize = 8;
|
||||||
|
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
|
||||||
|
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
|
||||||
|
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
|
||||||
|
static const int _autoAddFilterModeFlag = 1;
|
||||||
static const List<_RadioPreset> _radioPresets = [
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
_RadioPreset(
|
_RadioPreset(
|
||||||
id: 'australia',
|
id: 'australia',
|
||||||
@@ -166,16 +178,30 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
late TextEditingController _lonController;
|
late TextEditingController _lonController;
|
||||||
late TextEditingController _freqController;
|
late TextEditingController _freqController;
|
||||||
late TextEditingController _txPowerController;
|
late TextEditingController _txPowerController;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
|
|
||||||
bool _telemetryEnabled = false;
|
bool _telemetryEnabled = false;
|
||||||
bool _repeatEnabled = false;
|
bool _repeatEnabled = false;
|
||||||
|
bool _autoAddDiscoveredContactsEnabled = true;
|
||||||
|
bool _autoAddUsersEnabled = true;
|
||||||
|
bool _autoAddRepeatersEnabled = true;
|
||||||
|
bool _autoAddRoomServersEnabled = true;
|
||||||
|
bool _autoAddSensorsEnabled = true;
|
||||||
|
bool _overwriteOldestAutoAddEnabled = false;
|
||||||
bool _showCustomRadioSettings = false;
|
bool _showCustomRadioSettings = false;
|
||||||
bool _isSavingPublicInfo = false;
|
bool _isSavingPublicInfo = false;
|
||||||
bool _isSavingRadioSettings = false;
|
bool _isSavingRadioSettings = false;
|
||||||
|
bool _isSavingAutoDiscoverySettings = false;
|
||||||
|
bool _isClearingContacts = false;
|
||||||
|
bool _isClearingChannels = false;
|
||||||
bool _publicInfoSaved = false;
|
bool _publicInfoSaved = false;
|
||||||
bool _radioSettingsSaved = false;
|
bool _radioSettingsSaved = false;
|
||||||
|
bool _autoDiscoverySettingsSaved = false;
|
||||||
|
bool _autoDiscoverySettingsDirty = false;
|
||||||
|
String? _lastAutoDiscoverySignature;
|
||||||
String? _publicInfoError;
|
String? _publicInfoError;
|
||||||
String? _radioSettingsError;
|
String? _radioSettingsError;
|
||||||
|
String? _autoDiscoverySettingsError;
|
||||||
String _selectedBandwidth = '62.5 kHz';
|
String _selectedBandwidth = '62.5 kHz';
|
||||||
int _selectedSpreadingFactor = 8;
|
int _selectedSpreadingFactor = 8;
|
||||||
int _selectedCodingRate = 8;
|
int _selectedCodingRate = 8;
|
||||||
@@ -197,7 +223,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
_connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||||
|
final deviceInfo = _connectionProvider.deviceInfo;
|
||||||
|
|
||||||
_nameController = TextEditingController(
|
_nameController = TextEditingController(
|
||||||
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
|
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
|
||||||
@@ -252,6 +280,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
// Initialize repeat mode from device info (firmware v9+)
|
// Initialize repeat mode from device info (firmware v9+)
|
||||||
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
||||||
|
_syncAutoDiscoveryState(deviceInfo);
|
||||||
|
_lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo);
|
||||||
|
|
||||||
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
||||||
if (deviceInfo.clientRepeat != null &&
|
if (deviceInfo.clientRepeat != null &&
|
||||||
@@ -262,12 +292,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<ConnectionProvider>().getBatteryAndStorage();
|
_connectionProvider.getBatteryAndStorage();
|
||||||
|
unawaited(_connectionProvider.getAutoaddConfig());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_latController.dispose();
|
_latController.dispose();
|
||||||
_lonController.dispose();
|
_lonController.dispose();
|
||||||
@@ -342,6 +374,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncRadioPresetSelection() {
|
||||||
|
final freqResult = ValidationService().parseFrequency(_freqController.text);
|
||||||
|
final matchedPreset = freqResult.isSuccess
|
||||||
|
? _matchRadioPreset(
|
||||||
|
frequencyKhz: (freqResult.value! * 1000).round(),
|
||||||
|
bandwidth: _bandwidthToValue(_selectedBandwidth),
|
||||||
|
spreadingFactor: _selectedSpreadingFactor,
|
||||||
|
codingRate: _selectedCodingRate,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
_selectedRadioPreset = matchedPreset;
|
||||||
|
_showCustomRadioSettings = matchedPreset == null;
|
||||||
|
}
|
||||||
|
|
||||||
void _markPublicInfoDirty() {
|
void _markPublicInfoDirty() {
|
||||||
if (_publicInfoSaved || _publicInfoError != null) {
|
if (_publicInfoSaved || _publicInfoError != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -360,9 +407,83 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markAutoDiscoverySettingsDirty() {
|
||||||
|
_autoDiscoverySettingsDirty = true;
|
||||||
|
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
|
||||||
|
setState(() {
|
||||||
|
_autoDiscoverySettingsSaved = false;
|
||||||
|
_autoDiscoverySettingsError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _autoDiscoverySignature(DeviceInfo deviceInfo) {
|
||||||
|
return [
|
||||||
|
deviceInfo.manualAddContacts,
|
||||||
|
deviceInfo.autoAddUsers,
|
||||||
|
deviceInfo.autoAddRepeaters,
|
||||||
|
deviceInfo.autoAddRoomServers,
|
||||||
|
deviceInfo.autoAddSensors,
|
||||||
|
deviceInfo.autoAddOverwriteOldest,
|
||||||
|
].join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnectionProviderChanged() {
|
||||||
|
if (!mounted || _autoDiscoverySettingsDirty) return;
|
||||||
|
|
||||||
|
final deviceInfo = _connectionProvider.deviceInfo;
|
||||||
|
final nextSignature = _autoDiscoverySignature(deviceInfo);
|
||||||
|
if (nextSignature == _lastAutoDiscoverySignature) return;
|
||||||
|
|
||||||
|
_lastAutoDiscoverySignature = nextSignature;
|
||||||
|
setState(() {
|
||||||
|
_syncAutoDiscoveryState(deviceInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) {
|
||||||
|
return (deviceInfo.autoAddUsers ?? false) ||
|
||||||
|
(deviceInfo.autoAddRepeaters ?? false) ||
|
||||||
|
(deviceInfo.autoAddRoomServers ?? false) ||
|
||||||
|
(deviceInfo.autoAddSensors ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncAutoDiscoveryState(DeviceInfo deviceInfo) {
|
||||||
|
final hasFetchedAutoAddConfig =
|
||||||
|
deviceInfo.autoAddUsers != null ||
|
||||||
|
deviceInfo.autoAddRepeaters != null ||
|
||||||
|
deviceInfo.autoAddRoomServers != null ||
|
||||||
|
deviceInfo.autoAddSensors != null ||
|
||||||
|
deviceInfo.autoAddOverwriteOldest != null;
|
||||||
|
|
||||||
|
_autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig
|
||||||
|
? _hasAutoAddTargetsEnabled(deviceInfo)
|
||||||
|
: !(deviceInfo.manualAddContacts ?? false);
|
||||||
|
_autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true;
|
||||||
|
_autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true;
|
||||||
|
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
|
||||||
|
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
|
||||||
|
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _telemetryModesForSave(ConnectionProvider connectionProvider) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final telemetryEnabled =
|
||||||
|
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
|
||||||
|
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
|
||||||
|
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final telemetryEnabled =
|
||||||
|
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
|
||||||
|
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
|
||||||
|
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _savePublicInfo() async {
|
Future<void> _savePublicInfo() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final deviceInfo = connectionProvider.deviceInfo;
|
|
||||||
final validator = ValidationService();
|
final validator = ValidationService();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -410,7 +531,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
||||||
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 1,
|
advertLocationPolicy: 1,
|
||||||
);
|
);
|
||||||
@@ -421,7 +542,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Deny" (mode 0)
|
// Set telemetry modes to "Deny" (mode 0)
|
||||||
final telemetryModes = 0x00;
|
final telemetryModes = 0x00;
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 0,
|
advertLocationPolicy: 0,
|
||||||
);
|
);
|
||||||
@@ -522,6 +643,64 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAutoDiscoverySettings() async {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final autoAddUsers =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled;
|
||||||
|
final autoAddRepeaters =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled;
|
||||||
|
final autoAddRoomServers =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled;
|
||||||
|
final autoAddSensors =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
|
||||||
|
final overwriteOldest =
|
||||||
|
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSavingAutoDiscoverySettings = true;
|
||||||
|
_autoDiscoverySettingsSaved = false;
|
||||||
|
_autoDiscoverySettingsError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.setAutoaddConfig(
|
||||||
|
autoAddUsers: autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors,
|
||||||
|
overwriteOldest: overwriteOldest,
|
||||||
|
);
|
||||||
|
await connectionProvider.setOtherParams(
|
||||||
|
manualAddContacts: _autoAddFilterModeFlag,
|
||||||
|
telemetryModes: _telemetryModesForSave(connectionProvider),
|
||||||
|
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider),
|
||||||
|
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0,
|
||||||
|
);
|
||||||
|
await connectionProvider.getAutoaddConfig();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_syncAutoDiscoveryState(connectionProvider.deviceInfo);
|
||||||
|
_lastAutoDiscoverySignature = _autoDiscoverySignature(
|
||||||
|
connectionProvider.deviceInfo,
|
||||||
|
);
|
||||||
|
_isSavingAutoDiscoverySettings = false;
|
||||||
|
_autoDiscoverySettingsSaved = true;
|
||||||
|
_autoDiscoverySettingsDirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSavingAutoDiscoverySettings = false;
|
||||||
|
_autoDiscoverySettingsError = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.failedToSave(e.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _useCurrentLocation() async {
|
Future<void> _useCurrentLocation() async {
|
||||||
try {
|
try {
|
||||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
@@ -610,6 +789,267 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmFactoryReset() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Wipe device data'),
|
||||||
|
content: const Text(
|
||||||
|
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Wipe device'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.factoryResetDevice();
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(connectionProvider.error!),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Factory reset command sent. The device should reboot and disconnect shortly.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to wipe device data: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllContacts() async {
|
||||||
|
final contacts = context
|
||||||
|
.read<ContactsProvider>()
|
||||||
|
.contacts
|
||||||
|
.where((contact) => !contact.isChannel)
|
||||||
|
.toList();
|
||||||
|
if (contacts.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No device contacts to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all contacts'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear contacts'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final contact in List<Contact>.from(contacts)) {
|
||||||
|
await contactsProvider.removeContact(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
onRemoveFromDevice: connectionProvider.removeContact,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the updated local contact set to storage immediately.
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear contacts: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllChannels() async {
|
||||||
|
final channels = context
|
||||||
|
.read<ChannelsProvider>()
|
||||||
|
.channels
|
||||||
|
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No custom channels to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all channels'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear channels'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final channelsProvider = context.read<ChannelsProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final channel in List<Channel>.from(channels)) {
|
||||||
|
await connectionProvider.deleteChannel(channel.index);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force local channel/contact cache cleanup before the device resync.
|
||||||
|
for (final channel in channels) {
|
||||||
|
channelsProvider.removeChannel(channel.index);
|
||||||
|
final publicKeyBytes = Uint8List(32);
|
||||||
|
publicKeyBytes[0] = 0xFF;
|
||||||
|
publicKeyBytes[1] = channel.index;
|
||||||
|
final publicKeyHex = publicKeyBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
await contactsProvider.removeContact(publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.syncChannels();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear channels: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
@@ -704,6 +1144,164 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
|
title: 'Auto discovery',
|
||||||
|
subtitle:
|
||||||
|
'Control how the radio auto-adds discovered nodes to its contacts table.',
|
||||||
|
icon: Icons.person_search_rounded,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: _autoAddDiscoveredContactsEnabled
|
||||||
|
? Icons.person_add_alt_1
|
||||||
|
: Icons.person_add_disabled,
|
||||||
|
title: 'Enable automatic adding',
|
||||||
|
description:
|
||||||
|
'Turn this off to keep discoveries manual-only on the radio.',
|
||||||
|
accentColor: _autoAddDiscoveredContactsEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddDiscoveredContactsEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddDiscoveredContactsEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.person_outline_rounded,
|
||||||
|
title: 'Auto-add users',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered user/chat nodes.',
|
||||||
|
accentColor: _autoAddUsersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddUsersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddUsersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.router_outlined,
|
||||||
|
title: 'Auto-add repeaters',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered repeater nodes.',
|
||||||
|
accentColor: _autoAddRepeatersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddRepeatersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddRepeatersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.meeting_room_outlined,
|
||||||
|
title: 'Auto-add room servers',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered room/server nodes.',
|
||||||
|
accentColor: _autoAddRoomServersEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddRoomServersEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddRoomServersEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.sensors_outlined,
|
||||||
|
title: 'Auto-add sensors',
|
||||||
|
description:
|
||||||
|
'Automatically store discovered sensor nodes.',
|
||||||
|
accentColor: _autoAddSensorsEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddSensorsEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddSensorsEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: Icons.history_toggle_off_rounded,
|
||||||
|
title: 'Overwrite oldest when full',
|
||||||
|
description:
|
||||||
|
'Allow the radio to replace the oldest contact when storage is full.',
|
||||||
|
accentColor: _overwriteOldestAutoAddEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _overwriteOldestAutoAddEnabled,
|
||||||
|
onChanged: _autoAddDiscoveredContactsEnabled
|
||||||
|
? (value) {
|
||||||
|
setState(() {
|
||||||
|
_overwriteOldestAutoAddEnabled = value;
|
||||||
|
_markAutoDiscoverySettingsDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
if (_autoDiscoverySettingsError != null) ...[
|
||||||
|
Text(
|
||||||
|
_autoDiscoverySettingsError!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: _SaveActionButton(
|
||||||
|
onPressed: _isSavingAutoDiscoverySettings
|
||||||
|
? null
|
||||||
|
: _saveAutoDiscoverySettings,
|
||||||
|
isSaving: _isSavingAutoDiscoverySettings,
|
||||||
|
isSaved: _autoDiscoverySettingsSaved,
|
||||||
|
label: 'Save discovery settings',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
_ConfigSectionCard(
|
_ConfigSectionCard(
|
||||||
title: AppLocalizations.of(context)!.publicInfo,
|
title: AppLocalizations.of(context)!.publicInfo,
|
||||||
subtitle: 'Choose the name and location this device shares.',
|
subtitle: 'Choose the name and location this device shares.',
|
||||||
@@ -840,6 +1438,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<_RadioPreset?>(
|
DropdownButtonFormField<_RadioPreset?>(
|
||||||
|
key: ValueKey(_selectedRadioPreset?.id ?? 'custom'),
|
||||||
initialValue: _selectedRadioPreset,
|
initialValue: _selectedRadioPreset,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Radio preset',
|
labelText: 'Radio preset',
|
||||||
@@ -931,16 +1530,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
decimal: true,
|
decimal: true,
|
||||||
),
|
),
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_selectedRadioPreset != null) {
|
setState(_syncRadioPresetSelection);
|
||||||
setState(() {
|
|
||||||
_selectedRadioPreset = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_markRadioSettingsDirty();
|
_markRadioSettingsDirty();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey('bandwidth-$_selectedBandwidth'),
|
||||||
initialValue: _selectedBandwidth,
|
initialValue: _selectedBandwidth,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(
|
labelText: AppLocalizations.of(
|
||||||
@@ -962,7 +1558,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedBandwidth = newValue;
|
_selectedBandwidth = newValue;
|
||||||
_selectedRadioPreset = null;
|
_syncRadioPresetSelection();
|
||||||
});
|
});
|
||||||
_markRadioSettingsDirty();
|
_markRadioSettingsDirty();
|
||||||
}
|
}
|
||||||
@@ -970,6 +1566,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
|
key: ValueKey(
|
||||||
|
'spreading-factor-$_selectedSpreadingFactor',
|
||||||
|
),
|
||||||
initialValue: _selectedSpreadingFactor,
|
initialValue: _selectedSpreadingFactor,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(
|
labelText: AppLocalizations.of(
|
||||||
@@ -993,7 +1592,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedSpreadingFactor = newValue;
|
_selectedSpreadingFactor = newValue;
|
||||||
_selectedRadioPreset = null;
|
_syncRadioPresetSelection();
|
||||||
});
|
});
|
||||||
_markRadioSettingsDirty();
|
_markRadioSettingsDirty();
|
||||||
}
|
}
|
||||||
@@ -1001,6 +1600,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
|
key: ValueKey('coding-rate-$_selectedCodingRate'),
|
||||||
initialValue: _selectedCodingRate,
|
initialValue: _selectedCodingRate,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(
|
labelText: AppLocalizations.of(
|
||||||
@@ -1024,7 +1624,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedCodingRate = newValue;
|
_selectedCodingRate = newValue;
|
||||||
_selectedRadioPreset = null;
|
_syncRadioPresetSelection();
|
||||||
});
|
});
|
||||||
_markRadioSettingsDirty();
|
_markRadioSettingsDirty();
|
||||||
}
|
}
|
||||||
@@ -1099,6 +1699,124 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
|
title: 'Danger zone',
|
||||||
|
subtitle: 'Destructive device actions.',
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.errorContainer.withValues(
|
||||||
|
alpha: 0.55,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.error.withValues(alpha: 0.28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.delete_forever_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Wipe data on device',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Erase contacts, keys, and radio settings from the connected MeshCore device and return it to factory defaults.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllContacts,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingContacts
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.people_alt_outlined),
|
||||||
|
label: const Text('Clear all contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllChannels,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingChannels
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.forum_outlined),
|
||||||
|
label: const Text('Clear all channels'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmFactoryReset,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.error,
|
||||||
|
foregroundColor: colorScheme.onError,
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.delete_forever_rounded),
|
||||||
|
label: const Text('Wipe device data'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
701
lib/screens/discovery_screen.dart
Normal file
701
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
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>();
|
||||||
|
|
||||||
|
final canAddDirectly = _canAddPendingAdvertDirectly(advert);
|
||||||
|
if (canAddDirectly) {
|
||||||
|
final added = await _addPendingAdvertToRadio(
|
||||||
|
advert,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await connectionProvider.getContact(advert.publicKey);
|
||||||
|
if (connectionProvider.error == 'Not found') {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
final added = await _addPendingAdvertToRadio(
|
||||||
|
advert,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _canAddPendingAdvertDirectly(PendingAdvert advert) {
|
||||||
|
return advert.publicKey.length == 32 &&
|
||||||
|
advert.typeValue != null &&
|
||||||
|
advert.typeValue != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _addPendingAdvertToRadio(
|
||||||
|
PendingAdvert advert, {
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
}) async {
|
||||||
|
final contact = _contactFromPendingAdvert(advert);
|
||||||
|
await connectionProvider.addOrUpdateContact(contact);
|
||||||
|
|
||||||
|
final addError = connectionProvider.error;
|
||||||
|
if (addError != null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add contact: $addError')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsProvider.addOrUpdateContact(
|
||||||
|
contact,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final pendingCount =
|
||||||
|
contactsProvider.pendingAdverts.length;
|
||||||
|
return Text(
|
||||||
|
pendingCount > 0
|
||||||
|
? 'Discovery ($pendingCount)'
|
||||||
|
: 'Discovery',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -753,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,
|
||||||
@@ -765,7 +802,11 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
);
|
);
|
||||||
case _HomeTab.sensors:
|
case _HomeTab.sensors:
|
||||||
return const SensorsTab();
|
return SensorsTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.sensors &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
|
);
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
return MapTab(
|
return MapTab(
|
||||||
onFullscreenChanged: (isFullscreen) {
|
onFullscreenChanged: (isFullscreen) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -11,6 +12,8 @@ import '../services/live_traffic_summary.dart';
|
|||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_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 '../widgets/compact_signal_indicator.dart';
|
||||||
|
import '../widgets/messages/message_trace_sheet.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
|
||||||
T? _maybeProvider<T>(BuildContext context) {
|
T? _maybeProvider<T>(BuildContext context) {
|
||||||
@@ -134,11 +137,12 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
final routeHashCounts = _RouteHashCounts.fromContacts(
|
final routeHashCounts = _RouteHashCounts.fromContacts(
|
||||||
contactsProvider?.contacts ?? const <Contact>[],
|
contactsProvider?.contacts ?? const <Contact>[],
|
||||||
);
|
);
|
||||||
final packetTypes = unfilteredSnapshot.visibleEntries
|
final packetTypes =
|
||||||
.map((entry) => entry.payloadLabel)
|
unfilteredSnapshot.visibleEntries
|
||||||
.toSet()
|
.map((entry) => entry.payloadLabel)
|
||||||
.toList()
|
.toSet()
|
||||||
..sort();
|
.toList()
|
||||||
|
..sort();
|
||||||
final filteredEntries = snapshot.visibleEntries;
|
final filteredEntries = snapshot.visibleEntries;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -202,7 +206,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.radar_rounded,
|
Icons.radar_rounded,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.45),
|
color: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -218,7 +224,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
|
|||||||
? 'This view only shows in-memory traffic while it is active.'
|
? 'This view only shows in-memory traffic while it is active.'
|
||||||
: 'Try a different packet type or switch back to All.',
|
: 'Try a different packet type or switch back to All.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -345,7 +353,7 @@ class _SummaryPanel extends StatelessWidget {
|
|||||||
label: 'RX packets',
|
label: 'RX packets',
|
||||||
value: '${snapshot.rxCount}',
|
value: '${snapshot.rxCount}',
|
||||||
subtitle: totalRxCount == null
|
subtitle: totalRxCount == null
|
||||||
? 'Last 60 sec'
|
? _windowSummaryLabel(snapshot.windowDuration)
|
||||||
: 'Device total $totalRxCount',
|
: 'Device total $totalRxCount',
|
||||||
),
|
),
|
||||||
_MetricTile(
|
_MetricTile(
|
||||||
@@ -488,7 +496,9 @@ class _FilterChip extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
|
border: Border.all(
|
||||||
|
color: color.withValues(alpha: selected ? 0.45 : 0.22),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -504,12 +514,15 @@ class _FilterChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _windowLabel(Duration duration) {
|
String _windowLabel(Duration duration) {
|
||||||
if (duration.inMinutes >= 60) {
|
|
||||||
return '${duration.inMinutes} min';
|
|
||||||
}
|
|
||||||
return '${duration.inMinutes} min';
|
return '${duration.inMinutes} min';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _windowSummaryLabel(Duration duration) {
|
||||||
|
final minutes = duration.inMinutes;
|
||||||
|
if (minutes == 1) return 'Last 1 min';
|
||||||
|
return 'Last $minutes min';
|
||||||
|
}
|
||||||
|
|
||||||
class _MetricTile extends StatelessWidget {
|
class _MetricTile extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
@@ -527,7 +540,7 @@ class _MetricTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: 160,
|
width: 160,
|
||||||
height: 128,
|
height: 136,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.82),
|
||||||
@@ -638,103 +651,103 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final accent = isRx ? Colors.green : Colors.blue;
|
final accent = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final routePreview = _resolvedRoutePreview(context, entry);
|
|
||||||
final originDistance = _originDistanceLabel(context, entry);
|
final originDistance = _originDistanceLabel(context, entry);
|
||||||
|
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
|
||||||
|
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
|
||||||
|
|
||||||
return Container(
|
return Material(
|
||||||
padding: const EdgeInsets.all(14),
|
color: Colors.transparent,
|
||||||
decoration: BoxDecoration(
|
child: InkWell(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
||||||
|
onLongPress: () => _showTraceSheet(context, entry),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
child: Container(
|
||||||
),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
decoration: BoxDecoration(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(18),
|
||||||
Row(
|
border: Border.all(color: accent.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: accent.withValues(alpha: 0.14),
|
padding: const EdgeInsets.symmetric(
|
||||||
borderRadius: BorderRadius.circular(999),
|
horizontal: 10,
|
||||||
),
|
vertical: 6,
|
||||||
child: Text(
|
),
|
||||||
isRx ? 'RX' : 'TX',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
color: accent.withValues(alpha: 0.14),
|
||||||
color: accent,
|
borderRadius: BorderRadius.circular(999),
|
||||||
fontWeight: FontWeight.w800,
|
),
|
||||||
fontSize: 12,
|
child: Text(
|
||||||
),
|
isRx ? 'RX' : 'TX',
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: accent,
|
||||||
const SizedBox(width: 8),
|
fontWeight: FontWeight.w800,
|
||||||
Expanded(
|
fontSize: 12,
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
entry.payloadLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (entry.payloadMeaning != null)
|
),
|
||||||
Text(
|
const SizedBox(width: 8),
|
||||||
entry.payloadMeaning!,
|
Expanded(
|
||||||
maxLines: 1,
|
child: Column(
|
||||||
overflow: TextOverflow.ellipsis,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 11,
|
Text(
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
packetDetails.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (entry.payloadMeaning != null)
|
||||||
|
Text(
|
||||||
|
entry.payloadMeaning!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (signalMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
CompactSignalIndicator(metric: signalMetric),
|
||||||
|
] else
|
||||||
|
Text(
|
||||||
|
_timeAgo(log.timestamp, now),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(height: 10),
|
||||||
_timeAgo(log.timestamp, now),
|
_PacketInfoLine(
|
||||||
style: TextStyle(
|
text:
|
||||||
fontSize: 12,
|
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
|
||||||
),
|
if (packetDetails.pathLine != null)
|
||||||
const SizedBox(height: 10),
|
_PacketInfoLine(text: packetDetails.pathLine!),
|
||||||
Wrap(
|
if (packetDetails.pathHashLine != null)
|
||||||
spacing: 8,
|
_PacketInfoLine(text: packetDetails.pathHashLine!),
|
||||||
runSpacing: 8,
|
if (packetDetails.endpointLine != null)
|
||||||
children: [
|
_PacketInfoLine(text: packetDetails.endpointLine!),
|
||||||
_PacketMetaChip(
|
|
||||||
label: '${log.rawData.length} bytes',
|
|
||||||
onTap: () => _showPacketBytesSheet(context, log.rawData),
|
|
||||||
),
|
|
||||||
if (rxInfo?.rssiDbm != null)
|
|
||||||
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
|
|
||||||
if (rxInfo?.snrDb != null)
|
|
||||||
_PacketMetaChip(
|
|
||||||
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
|
||||||
),
|
|
||||||
if (entry.hopCount != null)
|
|
||||||
_PacketMetaChip(label: '${entry.hopCount} hops'),
|
|
||||||
if (entry.isMultiHop)
|
|
||||||
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
|
|
||||||
if (originDistance != null)
|
if (originDistance != null)
|
||||||
_PacketMetaChip(label: 'Origin $originDistance'),
|
_PacketInfoLine(text: 'Origin: $originDistance'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
Text(
|
|
||||||
routePreview,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -745,36 +758,11 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
return '${diff.inMinutes}m ago';
|
return '${diff.inMinutes}m ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _resolvedRoutePreview(
|
static String _formatClock(DateTime timestamp) {
|
||||||
BuildContext context,
|
final hour = timestamp.hour.toString().padLeft(2, '0');
|
||||||
LiveTrafficEntry entry,
|
final minute = timestamp.minute.toString().padLeft(2, '0');
|
||||||
) {
|
final second = timestamp.second.toString().padLeft(2, '0');
|
||||||
final route = entry.route;
|
return '$hour:$minute:$second';
|
||||||
if (route == null || route.hopHashes.isEmpty) {
|
|
||||||
return entry.routePreview;
|
|
||||||
}
|
|
||||||
|
|
||||||
final contactsProvider = _maybeProvider<ContactsProvider>(context);
|
|
||||||
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
|
|
||||||
if (contactsProvider == null && connectionProvider == null) {
|
|
||||||
return entry.routePreview;
|
|
||||||
}
|
|
||||||
final ownLatLng = _ownLatLng(connectionProvider);
|
|
||||||
|
|
||||||
final resolvedLabels = route.hopHashes.map((hashHex) {
|
|
||||||
final resolved = LogRxRouteDecoder.resolveHash(
|
|
||||||
hashHex,
|
|
||||||
contacts: contactsProvider?.contacts ?? const <Contact>[],
|
|
||||||
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
|
|
||||||
ownName:
|
|
||||||
connectionProvider?.deviceInfo.selfName ??
|
|
||||||
connectionProvider?.deviceInfo.displayName,
|
|
||||||
ownLatitude: ownLatLng?.latitude,
|
|
||||||
ownLongitude: ownLatLng?.longitude,
|
|
||||||
);
|
|
||||||
return _compactNodeLabel(resolved);
|
|
||||||
}).toList();
|
|
||||||
return resolvedLabels.join(' -> ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _originDistanceLabel(
|
static String? _originDistanceLabel(
|
||||||
@@ -853,11 +841,16 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
final hex = chunk
|
final hex = chunk
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||||
.join(' ');
|
.join(' ');
|
||||||
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
|
hexLines.add(
|
||||||
|
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ascii = data
|
final ascii = data
|
||||||
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
|
.map(
|
||||||
|
(byte) =>
|
||||||
|
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
|
||||||
|
)
|
||||||
.join();
|
.join();
|
||||||
|
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
@@ -883,10 +876,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
style: TextStyle(color: scheme.onSurfaceVariant),
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'Hex',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -906,10 +896,7 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
|
||||||
'ASCII',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -937,17 +924,187 @@ class _LiveTrafficCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _compactNodeLabel(ResolvedNodeHash node) {
|
static Future<void> _showTraceSheet(
|
||||||
if (node.isOwnNode) {
|
BuildContext context,
|
||||||
return node.label;
|
LiveTrafficEntry entry,
|
||||||
|
) {
|
||||||
|
final route = entry.route;
|
||||||
|
if (route == null || route.pathBytes.isEmpty) {
|
||||||
|
return Future.value();
|
||||||
}
|
}
|
||||||
if (node.matchCount > 0) {
|
return showModalBottomSheet<void>(
|
||||||
return node.label;
|
context: context,
|
||||||
}
|
isScrollControlled: true,
|
||||||
return node.hexLabel;
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (context) => MessageTraceSheet.packetPath(
|
||||||
|
packetPath: route.pathBytes,
|
||||||
|
descriptionOverride:
|
||||||
|
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
|
||||||
|
noRelayMatchTextOverride:
|
||||||
|
'No named nodes could be matched for this packet path.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LiveTrafficPacketDetails {
|
||||||
|
final String title;
|
||||||
|
final String packetHashHex;
|
||||||
|
final String? pathLine;
|
||||||
|
final String? pathHashLine;
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _LiveTrafficPacketDetails({
|
||||||
|
required this.title,
|
||||||
|
required this.packetHashHex,
|
||||||
|
required this.pathLine,
|
||||||
|
required this.pathHashLine,
|
||||||
|
required this.endpointLine,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
|
||||||
|
final route = entry.route;
|
||||||
|
final payloadType = route?.payloadType;
|
||||||
|
final parsedPayload = _ParsedTrafficPayload.tryParse(
|
||||||
|
entry.log.rawData,
|
||||||
|
route,
|
||||||
|
);
|
||||||
|
final title = switch (payloadType) {
|
||||||
|
0x00 => 'FLOOD REQUEST',
|
||||||
|
0x01 => 'FLOOD RESPONSE',
|
||||||
|
0x02 => 'FLOOD TEXT',
|
||||||
|
0x03 => 'FLOOD ACK',
|
||||||
|
0x04 => 'FLOOD ADVERTISEMENT',
|
||||||
|
0x05 => 'FLOOD GROUP_TEXT',
|
||||||
|
0x06 => 'FLOOD GROUP_DATA',
|
||||||
|
0x07 => 'FLOOD ANON_REQUEST',
|
||||||
|
0x08 => 'FLOOD RETURNED_PATH',
|
||||||
|
0x09 => 'FLOOD TRACE_PATH',
|
||||||
|
0x0A => 'FLOOD MULTIPART',
|
||||||
|
0x0B => 'FLOOD CONTROL',
|
||||||
|
_ => entry.payloadLabel.toUpperCase(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final hopHashes = route?.hopHashes ?? const <String>[];
|
||||||
|
final pathLine = hopHashes.isEmpty
|
||||||
|
? null
|
||||||
|
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
|
||||||
|
final pathHashLine = route == null
|
||||||
|
? null
|
||||||
|
: 'Path Hashes: ${route.hashSize}-byte per hop';
|
||||||
|
|
||||||
|
return _LiveTrafficPacketDetails(
|
||||||
|
title: title,
|
||||||
|
packetHashHex: _packetHash(entry.log.rawData),
|
||||||
|
pathLine: pathLine,
|
||||||
|
pathHashLine: pathHashLine,
|
||||||
|
endpointLine: parsedPayload?.endpointLine,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _packetHash(List<int> bytes) {
|
||||||
|
final digest = md5.convert(bytes).bytes;
|
||||||
|
return digest
|
||||||
|
.take(8)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ParsedTrafficPayload {
|
||||||
|
final String? endpointLine;
|
||||||
|
|
||||||
|
const _ParsedTrafficPayload({this.endpointLine});
|
||||||
|
|
||||||
|
static _ParsedTrafficPayload? tryParse(
|
||||||
|
List<int> rawData,
|
||||||
|
DecodedLogRxRoute? route,
|
||||||
|
) {
|
||||||
|
if (rawData.length < 5 ||
|
||||||
|
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawPacketData = rawData.sublist(3);
|
||||||
|
if (rawPacketData.length < 2) return null;
|
||||||
|
|
||||||
|
final header = rawPacketData[0];
|
||||||
|
final routeType = header & 0x03;
|
||||||
|
final payloadType = (header >> 2) & 0x0F;
|
||||||
|
|
||||||
|
var index = 1;
|
||||||
|
if (routeType == 0x00 || routeType == 0x03) {
|
||||||
|
if (rawPacketData.length < index + 5) return null;
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawPacketData.length <= index) return null;
|
||||||
|
final pathDescriptor = rawPacketData[index++];
|
||||||
|
final pathByteLen =
|
||||||
|
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
|
||||||
|
(pathDescriptor == 0xFF ? 0 : null);
|
||||||
|
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
index += pathByteLen;
|
||||||
|
final payload = rawPacketData.sublist(index);
|
||||||
|
final senderHash = route?.hopHashes.isNotEmpty == true
|
||||||
|
? route!.hopHashes.first
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x05:
|
||||||
|
case 0x06:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine:
|
||||||
|
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
);
|
||||||
|
case 0x00:
|
||||||
|
case 0x01:
|
||||||
|
case 0x07:
|
||||||
|
if (payload.isEmpty) return const _ParsedTrafficPayload();
|
||||||
|
final destinationHash = payload.first
|
||||||
|
.toRadixString(16)
|
||||||
|
.padLeft(2, '0')
|
||||||
|
.toUpperCase();
|
||||||
|
return _ParsedTrafficPayload(
|
||||||
|
endpointLine: senderHash == null
|
||||||
|
? 'To: <$destinationHash>'
|
||||||
|
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return const _ParsedTrafficPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PacketInfoLine extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _PacketInfoLine({required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _GeoPoint {
|
class _GeoPoint {
|
||||||
final double latitude;
|
final double latitude;
|
||||||
final double longitude;
|
final double longitude;
|
||||||
@@ -955,44 +1112,6 @@ class _GeoPoint {
|
|||||||
const _GeoPoint(this.latitude, this.longitude);
|
const _GeoPoint(this.latitude, this.longitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PacketMetaChip extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final bool emphasized;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _PacketMetaChip({
|
|
||||||
required this.label,
|
|
||||||
this.emphasized = false,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final scheme = Theme.of(context).colorScheme;
|
|
||||||
final color = emphasized ? scheme.primary : scheme.outline;
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
border: Border.all(color: color.withValues(alpha: 0.22)),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
|
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -85,7 +93,8 @@ 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();
|
||||||
_loadVoiceSettings();
|
_loadVoiceSettings();
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1712,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);
|
||||||
@@ -1810,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;
|
||||||
|
|
||||||
@@ -1848,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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ 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';
|
||||||
@@ -23,12 +25,12 @@ 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';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/connection_mode_selector.dart';
|
|
||||||
import '../widgets/update_dialog.dart';
|
import '../widgets/update_dialog.dart';
|
||||||
import 'sar_template_management_screen.dart';
|
import 'sar_template_management_screen.dart';
|
||||||
import 'welcome_wizard_screen.dart';
|
import 'welcome_wizard_screen.dart';
|
||||||
@@ -72,6 +74,14 @@ 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 _discoveryNotificationsEnabled = true;
|
||||||
|
bool _updateNotificationsEnabled = true;
|
||||||
|
bool _muteForegroundNotifications = true;
|
||||||
bool _isDeveloperModeEnabled = false;
|
bool _isDeveloperModeEnabled = false;
|
||||||
DateTime? _onlineTraceCacheUpdatedAt;
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
bool _isClearingOnlineTraceCache = false;
|
bool _isClearingOnlineTraceCache = false;
|
||||||
@@ -93,6 +103,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
_loadDeveloperMode();
|
_loadDeveloperMode();
|
||||||
_loadOnlineTraceCacheStatus();
|
_loadOnlineTraceCacheStatus();
|
||||||
|
_loadMapPreferences();
|
||||||
|
_loadNotificationPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -130,6 +142,19 @@ 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;
|
||||||
|
_discoveryNotificationsEnabled = service.discoveryNotificationsEnabled;
|
||||||
|
_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;
|
||||||
@@ -177,6 +202,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMapPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
|
||||||
|
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||||
|
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMapPreference(String key, bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadVoicePreferences() async {
|
Future<void> _loadVoicePreferences() async {
|
||||||
final value = await VoiceBitratePreferences.getBitrate();
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -980,6 +1020,88 @@ 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.contact_page_outlined),
|
||||||
|
title: const Text('Discovery notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify when new contacts appear in Discovery',
|
||||||
|
),
|
||||||
|
value: _discoveryNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_discoveryNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setDiscoveryNotificationsEnabled(
|
||||||
|
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>(
|
||||||
@@ -1025,19 +1147,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'),
|
||||||
@@ -1154,6 +1263,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) =>
|
||||||
@@ -1362,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
if (!kIsWeb) ...[
|
|
||||||
_buildSectionHeader('Network Sharing'),
|
|
||||||
const ConnectionModeSelector(),
|
|
||||||
],
|
|
||||||
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
/// Cayenne LPP (Low Power Payload) data parser
|
/// Cayenne LPP (Low Power Payload) data parser
|
||||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||||
class CayenneLppParser {
|
class CayenneLppParser {
|
||||||
|
static const int _selfTelemetryChannel = 1;
|
||||||
|
static const int _lppGenericSensor = 100;
|
||||||
|
static const int _lppCurrent = 117;
|
||||||
|
static const int _lppFrequency = 118;
|
||||||
|
static const int _lppPercentage = 120;
|
||||||
|
static const int _lppAltitude = 121;
|
||||||
|
static const int _lppConcentration = 125;
|
||||||
|
static const int _lppPower = 128;
|
||||||
|
static const int _lppSpeed = 129;
|
||||||
|
static const int _lppDistance = 130;
|
||||||
|
static const int _lppEnergy = 131;
|
||||||
|
static const int _lppDirection = 132;
|
||||||
|
static const int _lppUnixTime = 133;
|
||||||
|
static const int _lppColour = 135;
|
||||||
|
static const int _lppSwitch = 142;
|
||||||
|
|
||||||
/// Parse Cayenne LPP data into ContactTelemetry
|
/// Parse Cayenne LPP data into ContactTelemetry
|
||||||
static ContactTelemetry parse(Uint8List data) {
|
static ContactTelemetry parse(Uint8List data) {
|
||||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||||
@@ -25,7 +41,8 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
if (fieldCount > 0 &&
|
||||||
|
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||||
'${data.length - reader.remainingBytesCount}',
|
'${data.length - reader.remainingBytesCount}',
|
||||||
@@ -65,14 +82,16 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Analog Input (raw): $rawValue');
|
debugPrint(' Analog Input (raw): $rawValue');
|
||||||
debugPrint(' Analog Input (volts): ${value}V');
|
debugPrint(' Analog Input (volts): ${value}V');
|
||||||
extraSensorData['analog_input_$channel'] = value;
|
if (_isBatteryChannel(channel)) {
|
||||||
// If this is a battery reading
|
|
||||||
if (channel == 0 || channel == 1) {
|
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -85,7 +104,7 @@ class CayenneLppParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppIlluminanceSensor:
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
final value = reader.readUInt16BE();
|
final value = reader.readUInt16BE().toDouble();
|
||||||
debugPrint(' Illuminance: $value lux');
|
debugPrint(' Illuminance: $value lux');
|
||||||
extraSensorData['illuminance_$channel'] = value;
|
extraSensorData['illuminance_$channel'] = value;
|
||||||
break;
|
break;
|
||||||
@@ -98,18 +117,36 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppTemperatureSensor:
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
final rawValue = reader.readInt16BE();
|
final rawValue = reader.readInt16BE();
|
||||||
temperature = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Temperature (raw): $rawValue');
|
debugPrint(' Temperature (raw): $rawValue');
|
||||||
debugPrint(
|
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
|
||||||
' Temperature: ${temperature.toStringAsFixed(1)}°C',
|
if (channel == _selfTelemetryChannel) {
|
||||||
);
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['temperature_$channel'] = value;
|
||||||
|
if (temperature == null) {
|
||||||
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppHumiditySensor:
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
final rawValue = reader.readByte();
|
final rawValue = reader.readByte();
|
||||||
humidity = rawValue / 2.0;
|
final value = rawValue / 2.0;
|
||||||
debugPrint(' Humidity (raw): $rawValue');
|
debugPrint(' Humidity (raw): $rawValue');
|
||||||
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
|
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['humidity_$channel'] = value;
|
||||||
|
if (humidity == null) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppAccelerometer:
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
@@ -126,9 +163,19 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppBarometer:
|
case MeshCoreConstants.lppBarometer:
|
||||||
final rawValue = reader.readUInt16BE();
|
final rawValue = reader.readUInt16BE();
|
||||||
pressure = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Barometer (raw): $rawValue');
|
debugPrint(' Barometer (raw): $rawValue');
|
||||||
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
|
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['pressure_$channel'] = value;
|
||||||
|
if (pressure == null) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppVoltageSensor:
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
@@ -136,12 +183,17 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Voltage (raw): $rawValue');
|
debugPrint(' Voltage (raw): $rawValue');
|
||||||
debugPrint(' Voltage: ${value}V');
|
debugPrint(' Voltage: ${value}V');
|
||||||
// Treat voltage sensor as battery reading
|
if (_isBatteryChannel(channel)) {
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
debugPrint(
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
);
|
debugPrint(
|
||||||
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extraSensorData['voltage_$channel'] = value;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppGyrometer:
|
case MeshCoreConstants.lppGyrometer:
|
||||||
@@ -194,9 +246,111 @@ class CayenneLppParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gpsLocation = LatLng(lat, lon);
|
gpsLocation = LatLng(lat, lon);
|
||||||
|
extraSensorData[_sourceChannelKey('gps')] = channel;
|
||||||
extraSensorData['altitude_$channel'] = alt;
|
extraSensorData['altitude_$channel'] = alt;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case _lppGenericSensor:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Generic Sensor: $value');
|
||||||
|
extraSensorData['generic_sensor_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCurrent:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Current (raw): $rawValue');
|
||||||
|
debugPrint(' Current: ${value}A');
|
||||||
|
extraSensorData['current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFrequency:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Frequency: ${value}Hz');
|
||||||
|
extraSensorData['frequency_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPercentage:
|
||||||
|
final value = reader.readByte().toDouble();
|
||||||
|
debugPrint(' Percentage: $value%');
|
||||||
|
if (_isBatteryChannel(channel)) {
|
||||||
|
batteryPercentage = value;
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['percentage_$channel'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAltitude:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue.toDouble();
|
||||||
|
debugPrint(' Altitude: ${value}m');
|
||||||
|
extraSensorData['altitude_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConcentration:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Concentration: ${value}ppm');
|
||||||
|
extraSensorData['concentration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPower:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Power: ${value}W');
|
||||||
|
extraSensorData['power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSpeed:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Speed: ${value}m/s');
|
||||||
|
extraSensorData['speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDistance:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Distance: ${value}m');
|
||||||
|
extraSensorData['distance_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppEnergy:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Energy: ${value}kWh');
|
||||||
|
extraSensorData['energy_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDirection:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Direction: $value°');
|
||||||
|
extraSensorData['direction_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUnixTime:
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
debugPrint(' Unix time: $value');
|
||||||
|
extraSensorData['unixtime_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppColour:
|
||||||
|
final red = reader.readByte();
|
||||||
|
final green = reader.readByte();
|
||||||
|
final blue = reader.readByte();
|
||||||
|
debugPrint(' Colour: r=$red, g=$green, b=$blue');
|
||||||
|
extraSensorData['colour_$channel'] = {
|
||||||
|
'r': red,
|
||||||
|
'g': green,
|
||||||
|
'b': blue,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSwitch:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Switch: $value');
|
||||||
|
extraSensorData['switch_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
||||||
@@ -258,6 +412,16 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isBatteryChannel(int channel) =>
|
||||||
|
channel == 0 || channel == _selfTelemetryChannel;
|
||||||
|
|
||||||
|
static int _readUInt32BE(BufferReader reader) {
|
||||||
|
final bytes = reader.readBytes(4);
|
||||||
|
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey';
|
||||||
|
|
||||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
final start = data.length - remainingBytes;
|
final start = data.length - remainingBytes;
|
||||||
for (int i = start; i < data.length; i++) {
|
for (int i = start; i < data.length; i++) {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
|
|||||||
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 _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 {
|
||||||
@@ -126,6 +128,47 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||||
|
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||||
|
: adverts;
|
||||||
|
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_pendingAdvertsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_pendingAdvertsKey);
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -159,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,
|
||||||
@@ -166,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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,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,
|
||||||
@@ -188,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');
|
||||||
@@ -240,6 +295,25 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||||
|
if (json is! List) return [];
|
||||||
|
final result = <AdvertLocation>[];
|
||||||
|
for (final item in json) {
|
||||||
|
if (item is! Map<String, dynamic>) continue;
|
||||||
|
final lat = item['lat'];
|
||||||
|
final lon = item['lon'];
|
||||||
|
final tsMillis = item['tsMillis'];
|
||||||
|
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||||
|
result.add(
|
||||||
|
AdvertLocation(
|
||||||
|
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
return {
|
return {
|
||||||
'id': group.id,
|
'id': group.id,
|
||||||
@@ -247,6 +321,8 @@ class ContactStorageService {
|
|||||||
'label': group.label,
|
'label': group.label,
|
||||||
'query': group.query,
|
'query': group.query,
|
||||||
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
'matchPrefixes': group.matchPrefixes,
|
||||||
|
'isAutoGroup': group.isAutoGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +336,10 @@ class ContactStorageService {
|
|||||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
json['createdAtMillis'] as int,
|
json['createdAtMillis'] as int,
|
||||||
),
|
),
|
||||||
|
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||||
|
?.map((value) => value as String)
|
||||||
|
.toList(),
|
||||||
|
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ class LiveTrafficSummary {
|
|||||||
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
const txCount = 0;
|
const txCount = 0;
|
||||||
final totalCount = rxCount;
|
final totalCount = rxCount;
|
||||||
final packetsPerMinute = totalCount;
|
final packetsPerMinute =
|
||||||
|
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||||
|
|
||||||
return LiveTrafficSnapshot(
|
return LiveTrafficSnapshot(
|
||||||
windowStart: effectiveStart,
|
windowStart: effectiveStart,
|
||||||
|
|||||||
@@ -42,89 +42,100 @@ class MapMarkerService {
|
|||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
Position? userPosition,
|
Position? userPosition,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
final location = contact.displayLocation;
|
.map((contact) {
|
||||||
if (location == null) return null;
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
|
||||||
return Marker(
|
return Marker(
|
||||||
point: location,
|
point: location,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotate: false, // Don't rotate the entire marker with map
|
rotate: false, // Don't rotate the entire marker with map
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: -mapRotation * pi / 180,
|
angle: -mapRotation * pi / 180,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap != null ? () => onTap(contact) : null,
|
onTap: onTap != null ? () => onTap(contact) : null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 4,
|
||||||
color: getLocationAgeColor(contact),
|
vertical: 1,
|
||||||
borderRadius: BorderRadius.circular(3),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: getLocationAgeColor(contact),
|
||||||
contact.timeSinceLocationUpdate,
|
borderRadius: BorderRadius.circular(3),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: Colors.white,
|
child: Text(
|
||||||
fontSize: 9,
|
contact.timeSinceLocationUpdate,
|
||||||
fontWeight: FontWeight.bold,
|
style: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
),
|
fontSize: 9,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
// Marker icon
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BoxShape.rectangle
|
|
||||||
: BoxShape.circle,
|
|
||||||
borderRadius: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BorderRadius.circular(14)
|
|
||||||
: null,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: ContactAvatar(contact: contact, radius: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
// Name label (without emoji)
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 80),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 2),
|
||||||
maxLines: 1,
|
// Marker icon
|
||||||
textAlign: TextAlign.center,
|
Container(
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BoxShape.rectangle
|
||||||
|
: BoxShape.circle,
|
||||||
|
borderRadius:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BorderRadius.circular(14)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ContactAvatar(contact: contact, radius: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Name label (without emoji)
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
})
|
||||||
);
|
.whereType<Marker>()
|
||||||
}).whereType<Marker>().toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate markers for SAR events.
|
/// Generate markers for SAR events.
|
||||||
@@ -157,7 +168,10 @@ class MapMarkerService {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getSarMarkerColor(marker.type),
|
color: getSarMarkerColor(marker.type),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -188,7 +202,7 @@ class MapMarkerService {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.emoji, // Use custom emoji if available
|
marker.emoji, // Use custom emoji if available
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,13 +210,17 @@ class MapMarkerService {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
marker
|
||||||
|
.displayName, // Uses notes if available, otherwise type.displayName
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -269,7 +287,8 @@ class MapMarkerService {
|
|||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -299,8 +318,8 @@ class MapMarkerService {
|
|||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -368,6 +387,8 @@ class MapMarkerService {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -389,6 +410,8 @@ class MapMarkerService {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -466,7 +489,8 @@ class MapMarkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allPoints.isEmpty) {
|
if (allPoints.isEmpty) {
|
||||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
return defaultCenter ??
|
||||||
|
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = 0, lng = 0;
|
double lat = 0, lng = 0;
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -269,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 {
|
||||||
@@ -304,6 +329,8 @@ class MessageStorageService {
|
|||||||
Message message, {
|
Message message, {
|
||||||
MessageReceptionDetails? receptionDetails,
|
MessageReceptionDetails? receptionDetails,
|
||||||
}) {
|
}) {
|
||||||
|
final persistedPathBytes =
|
||||||
|
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -312,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,
|
||||||
@@ -440,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,
|
||||||
@@ -537,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,28 @@ 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 _prefDiscoveryEnabled = 'notifications_discovery_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 _discoveryNotificationsEnabled = 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 +53,32 @@ 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 discoveryNotificationsEnabled => _discoveryNotificationsEnabled;
|
||||||
|
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 +107,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 +174,54 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
|
||||||
|
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
|
||||||
|
_discoveryNotificationsEnabled =
|
||||||
|
prefs.getBool(_prefDiscoveryEnabled) ?? 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> setDiscoveryNotificationsEnabled(bool value) async {
|
||||||
|
_discoveryNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefDiscoveryEnabled, 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 +267,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 +313,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 +340,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 +522,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 +685,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 +765,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 (!_discoveryNotificationsEnabled) 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,785 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:shelf/shelf.dart' as shelf;
|
|
||||||
import 'package:shelf/shelf_io.dart' as io;
|
|
||||||
import 'package:nsd/nsd.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
import '../models/contact.dart';
|
|
||||||
import '../models/sse_server_config.dart';
|
|
||||||
import 'network_scanner_service.dart';
|
|
||||||
|
|
||||||
/// SSE Server Service
|
|
||||||
///
|
|
||||||
/// Provides a web server with SSE (Server-Sent Events) endpoints for
|
|
||||||
/// real-time message and contact updates, enabling multiple app instances
|
|
||||||
/// to share a single MeshCore BLE device.
|
|
||||||
///
|
|
||||||
/// Endpoints:
|
|
||||||
/// - GET /sse/messages - SSE stream for message updates
|
|
||||||
/// - GET /sse/contacts - SSE stream for contact updates
|
|
||||||
/// - POST /api/messages - Send message
|
|
||||||
/// - POST /api/messages/channel - Send channel message
|
|
||||||
/// - POST /api/contacts/sync - Trigger contact sync
|
|
||||||
/// - GET /api/messages/history - Get all messages
|
|
||||||
/// - GET /api/contacts - Get all contacts
|
|
||||||
/// - GET /api/status - Server health check
|
|
||||||
class SseServerService {
|
|
||||||
HttpServer? _server;
|
|
||||||
SseServerConfig? _config;
|
|
||||||
Registration? _bonjourRegistration;
|
|
||||||
|
|
||||||
/// Active SSE connections for messages
|
|
||||||
final Set<StreamController<String>> _messageStreams = {};
|
|
||||||
|
|
||||||
/// Active SSE connections for contacts
|
|
||||||
final Set<StreamController<String>> _contactStreams = {};
|
|
||||||
|
|
||||||
/// Message history (for new clients)
|
|
||||||
final List<Message> _messageHistory = [];
|
|
||||||
|
|
||||||
/// Contact list (for new clients)
|
|
||||||
final Map<String, Contact> _contacts = {};
|
|
||||||
|
|
||||||
/// Timer for cleaning up dead connections
|
|
||||||
Timer? _cleanupTimer;
|
|
||||||
|
|
||||||
/// Device name (for status endpoint)
|
|
||||||
String? _deviceName;
|
|
||||||
|
|
||||||
/// Set device name
|
|
||||||
void setDeviceName(String? name) {
|
|
||||||
_deviceName = name;
|
|
||||||
debugPrint('📝 [SseServer] Device name set to: $name');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback for when a client requests to send a message
|
|
||||||
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests to send a channel message
|
|
||||||
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests contact sync
|
|
||||||
Future<void> Function()? onSyncContacts;
|
|
||||||
|
|
||||||
/// Check if server is running
|
|
||||||
bool get isRunning => _server != null;
|
|
||||||
|
|
||||||
/// Get current configuration
|
|
||||||
SseServerConfig? get config => _config;
|
|
||||||
|
|
||||||
/// Get number of connected clients
|
|
||||||
int get connectedClients => _messageStreams.length;
|
|
||||||
|
|
||||||
/// CORS middleware
|
|
||||||
static shelf.Middleware get _corsHeaders {
|
|
||||||
return shelf.createMiddleware(
|
|
||||||
responseHandler: (shelf.Response response) {
|
|
||||||
return response.change(
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers':
|
|
||||||
'Origin, Content-Type, Authorization',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the SSE server
|
|
||||||
Future<void> startServer(SseServerConfig config) async {
|
|
||||||
if (_server != null) {
|
|
||||||
debugPrint('⚠️ [SseServer] Server already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_config = config;
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create shelf handler with CORS support
|
|
||||||
final handler = const shelf.Pipeline()
|
|
||||||
.addMiddleware(_corsHeaders)
|
|
||||||
.addMiddleware(shelf.logRequests())
|
|
||||||
.addHandler(_handleRequest);
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
_server = await io.serve(handler, config.host, config.port);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
|
||||||
|
|
||||||
// Start cleanup timer for dead connections
|
|
||||||
_startCleanupTimer();
|
|
||||||
|
|
||||||
// Register Bonjour/mDNS service
|
|
||||||
await _registerBonjourService(config);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Failed to start server: $e');
|
|
||||||
_server = null;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register Bonjour/mDNS service for network discovery
|
|
||||||
Future<void> _registerBonjourService(SseServerConfig config) async {
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
|
|
||||||
);
|
|
||||||
|
|
||||||
_bonjourRegistration = await register(
|
|
||||||
const Service(
|
|
||||||
name: 'MeshCore SSE Server',
|
|
||||||
type: NetworkScannerService.serviceType,
|
|
||||||
port: 0, // Will be set dynamically
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update with actual port
|
|
||||||
if (_bonjourRegistration != null) {
|
|
||||||
// Unregister and re-register with correct port
|
|
||||||
await unregister(_bonjourRegistration!);
|
|
||||||
_bonjourRegistration = await register(
|
|
||||||
Service(
|
|
||||||
name: 'MeshCore SSE Server',
|
|
||||||
type: NetworkScannerService.serviceType,
|
|
||||||
port: config.port,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'✅ [SseServer] Bonjour service registered on port ${config.port}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
|
||||||
// Don't throw - server can still work without Bonjour
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start cleanup timer to remove dead connections
|
|
||||||
void _startCleanupTimer() {
|
|
||||||
_cleanupTimer?.cancel();
|
|
||||||
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
|
|
||||||
_cleanupDeadConnections();
|
|
||||||
});
|
|
||||||
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up dead/closed connections
|
|
||||||
void _cleanupDeadConnections() {
|
|
||||||
// Clean up message streams
|
|
||||||
final deadMessageStreams = _messageStreams
|
|
||||||
.where((s) => s.isClosed)
|
|
||||||
.toList();
|
|
||||||
for (final stream in deadMessageStreams) {
|
|
||||||
_messageStreams.remove(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up contact streams
|
|
||||||
final deadContactStreams = _contactStreams
|
|
||||||
.where((s) => s.isClosed)
|
|
||||||
.toList();
|
|
||||||
for (final stream in deadContactStreams) {
|
|
||||||
_contactStreams.remove(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the SSE server
|
|
||||||
Future<void> stopServer() async {
|
|
||||||
if (_server == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🛑 [SseServer] Stopping server...');
|
|
||||||
|
|
||||||
// Stop cleanup timer
|
|
||||||
_cleanupTimer?.cancel();
|
|
||||||
_cleanupTimer = null;
|
|
||||||
|
|
||||||
// Close all SSE streams
|
|
||||||
for (final stream in _messageStreams) {
|
|
||||||
await stream.close();
|
|
||||||
}
|
|
||||||
_messageStreams.clear();
|
|
||||||
|
|
||||||
for (final stream in _contactStreams) {
|
|
||||||
await stream.close();
|
|
||||||
}
|
|
||||||
_contactStreams.clear();
|
|
||||||
|
|
||||||
// Unregister Bonjour service
|
|
||||||
if (_bonjourRegistration != null) {
|
|
||||||
try {
|
|
||||||
await unregister(_bonjourRegistration!);
|
|
||||||
debugPrint('✅ [SseServer] Bonjour service unregistered');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
|
|
||||||
}
|
|
||||||
_bonjourRegistration = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close HTTP server
|
|
||||||
await _server!.close(force: true);
|
|
||||||
_server = null;
|
|
||||||
_config = null;
|
|
||||||
|
|
||||||
debugPrint('✅ [SseServer] Server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main request handler
|
|
||||||
Future<shelf.Response> _handleRequest(shelf.Request request) async {
|
|
||||||
// Check authentication if token is configured
|
|
||||||
if (_config?.authToken != null) {
|
|
||||||
final authHeader = request.headers['authorization'];
|
|
||||||
if (authHeader != 'Bearer ${_config!.authToken}') {
|
|
||||||
return shelf.Response.forbidden('Invalid authentication token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final path = request.url.path;
|
|
||||||
final method = request.method;
|
|
||||||
|
|
||||||
debugPrint('📨 [SseServer] $method /$path');
|
|
||||||
|
|
||||||
// Route requests
|
|
||||||
if (method == 'GET' && path == 'sse/messages') {
|
|
||||||
return _handleSseMessages(request);
|
|
||||||
} else if (method == 'GET' && path == 'sse/contacts') {
|
|
||||||
return _handleSseContacts(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/messages') {
|
|
||||||
return _handlePostMessage(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/messages/channel') {
|
|
||||||
return _handlePostChannelMessage(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/contacts/sync') {
|
|
||||||
return _handlePostContactsSync(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/messages/history') {
|
|
||||||
return _handleGetMessageHistory(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/contacts') {
|
|
||||||
return _handleGetContacts(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/status') {
|
|
||||||
return _handleGetStatus(request);
|
|
||||||
} else if (method == 'GET' && path == '') {
|
|
||||||
return _handleRoot(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return shelf.Response.notFound('Not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle SSE messages stream
|
|
||||||
shelf.Response _handleSseMessages(shelf.Request request) {
|
|
||||||
return request.hijack((channel) async {
|
|
||||||
debugPrint(
|
|
||||||
'📥 [SseServer] New SSE client connected (messages) via hijack',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up the sink for sending data
|
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
|
||||||
|
|
||||||
// Send SSE headers
|
|
||||||
sink.add('HTTP/1.1 200 OK\r\n');
|
|
||||||
sink.add('Content-Type: text/event-stream\r\n');
|
|
||||||
sink.add('Cache-Control: no-cache\r\n');
|
|
||||||
sink.add('Connection: keep-alive\r\n');
|
|
||||||
sink.add('\r\n');
|
|
||||||
|
|
||||||
// Create controller for this connection
|
|
||||||
final controller = StreamController<String>();
|
|
||||||
_messageStreams.add(controller);
|
|
||||||
|
|
||||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
sink.add(': connected\n\n');
|
|
||||||
|
|
||||||
// Send initial message history
|
|
||||||
for (final message in _messageHistory) {
|
|
||||||
final event = _formatSseEvent('message', _messageToJson(message));
|
|
||||||
sink.add(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start keep-alive timer
|
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
|
||||||
timer,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
sink.add(': keepalive\n\n');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to controller for new messages to broadcast
|
|
||||||
final subscription = controller.stream.listen(
|
|
||||||
(data) {
|
|
||||||
try {
|
|
||||||
sink.add(data);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint('📤 [SseServer] Controller stream closed');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for channel to close
|
|
||||||
await channel.stream.drain();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
keepAliveTimer.cancel();
|
|
||||||
await subscription.cancel();
|
|
||||||
_messageStreams.remove(controller);
|
|
||||||
await controller.close();
|
|
||||||
|
|
||||||
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
|
|
||||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle SSE contacts stream
|
|
||||||
shelf.Response _handleSseContacts(shelf.Request request) {
|
|
||||||
return request.hijack((channel) async {
|
|
||||||
debugPrint(
|
|
||||||
'📥 [SseServer] New SSE client connected (contacts) via hijack',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up the sink for sending data
|
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
|
||||||
|
|
||||||
// Send SSE headers
|
|
||||||
sink.add('HTTP/1.1 200 OK\r\n');
|
|
||||||
sink.add('Content-Type: text/event-stream\r\n');
|
|
||||||
sink.add('Cache-Control: no-cache\r\n');
|
|
||||||
sink.add('Connection: keep-alive\r\n');
|
|
||||||
sink.add('\r\n');
|
|
||||||
|
|
||||||
// Create controller for this connection
|
|
||||||
final controller = StreamController<String>();
|
|
||||||
_contactStreams.add(controller);
|
|
||||||
|
|
||||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
sink.add(': connected\n\n');
|
|
||||||
|
|
||||||
// Send initial contact list
|
|
||||||
for (final contact in _contacts.values) {
|
|
||||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
|
||||||
sink.add(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start keep-alive timer
|
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
|
||||||
timer,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
sink.add(': keepalive\n\n');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to controller for new messages to broadcast
|
|
||||||
final subscription = controller.stream.listen(
|
|
||||||
(data) {
|
|
||||||
try {
|
|
||||||
sink.add(data);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint('📤 [SseServer] Controller stream closed');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for channel to close
|
|
||||||
await channel.stream.drain();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
keepAliveTimer.cancel();
|
|
||||||
await subscription.cancel();
|
|
||||||
_contactStreams.remove(controller);
|
|
||||||
await controller.close();
|
|
||||||
|
|
||||||
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
|
|
||||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST message request
|
|
||||||
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final recipientPublicKey = json['recipientPublicKey'] as String;
|
|
||||||
final text = json['text'] as String;
|
|
||||||
|
|
||||||
if (onSendMessage == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': 'Send message callback not configured'}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await onSendMessage!(recipientPublicKey, text);
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': success}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST message: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST channel message request
|
|
||||||
Future<shelf.Response> _handlePostChannelMessage(
|
|
||||||
shelf.Request request,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final channelIdx = json['channelIdx'] as int;
|
|
||||||
final text = json['text'] as String;
|
|
||||||
|
|
||||||
if (onSendChannelMessage == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({
|
|
||||||
'error': 'Send channel message callback not configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSendChannelMessage!(channelIdx, text);
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': true}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST contacts sync request
|
|
||||||
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
|
|
||||||
try {
|
|
||||||
if (onSyncContacts == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSyncContacts!();
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': true}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET message history request
|
|
||||||
shelf.Response _handleGetMessageHistory(shelf.Request request) {
|
|
||||||
final messages = _messageHistory.map(_messageToJson).toList();
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'messages': messages}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET contacts request
|
|
||||||
shelf.Response _handleGetContacts(shelf.Request request) {
|
|
||||||
final contacts = _contacts.values.map(_contactToJson).toList();
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'contacts': contacts}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET status request
|
|
||||||
shelf.Response _handleGetStatus(shelf.Request request) {
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({
|
|
||||||
'status': 'running',
|
|
||||||
'connectedClients': connectedClients,
|
|
||||||
'messageCount': _messageHistory.length,
|
|
||||||
'contactCount': _contacts.length,
|
|
||||||
'deviceName': _deviceName,
|
|
||||||
}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle root request (landing page)
|
|
||||||
shelf.Response _handleRoot(shelf.Request request) {
|
|
||||||
final html = '''
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MeshCore SAR - SSE Server</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; }
|
|
||||||
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
|
|
||||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
|
|
||||||
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 MeshCore SAR Server</h1>
|
|
||||||
<div class="status">✅ Server is running</div>
|
|
||||||
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
|
|
||||||
|
|
||||||
<h2>📡 SSE Endpoints</h2>
|
|
||||||
<div class="endpoint">GET /sse/messages</div>
|
|
||||||
<div class="endpoint">GET /sse/contacts</div>
|
|
||||||
|
|
||||||
<h2>🔧 API Endpoints</h2>
|
|
||||||
<div class="endpoint">POST /api/messages</div>
|
|
||||||
<div class="endpoint">POST /api/messages/channel</div>
|
|
||||||
<div class="endpoint">POST /api/contacts/sync</div>
|
|
||||||
<div class="endpoint">GET /api/messages/history</div>
|
|
||||||
<div class="endpoint">GET /api/contacts</div>
|
|
||||||
<div class="endpoint">GET /api/status</div>
|
|
||||||
|
|
||||||
<h2>📊 Stats</h2>
|
|
||||||
<p>Connected clients: <strong id="clients">Loading...</strong></p>
|
|
||||||
<p>Messages: <strong id="messages">Loading...</strong></p>
|
|
||||||
<p>Contacts: <strong id="contacts">Loading...</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function updateStats() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/status');
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('clients').textContent = data.connectedClients;
|
|
||||||
document.getElementById('messages').textContent = data.messageCount;
|
|
||||||
document.getElementById('contacts').textContent = data.contactCount;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch stats:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateStats();
|
|
||||||
setInterval(updateStats, 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''';
|
|
||||||
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast a new message to all SSE clients
|
|
||||||
void broadcastMessage(Message message) {
|
|
||||||
// Add to history (limit to 1000 messages)
|
|
||||||
_messageHistory.add(message);
|
|
||||||
if (_messageHistory.length > 1000) {
|
|
||||||
_messageHistory.removeAt(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
|
||||||
final event = _formatSseEvent('message', _messageToJson(message));
|
|
||||||
final deadStreams = <StreamController<String>>[];
|
|
||||||
|
|
||||||
for (final stream in _messageStreams) {
|
|
||||||
if (stream.isClosed) {
|
|
||||||
deadStreams.add(stream);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
stream.add(event);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
|
||||||
);
|
|
||||||
deadStreams.add(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove dead streams
|
|
||||||
for (final stream in deadStreams) {
|
|
||||||
_messageStreams.remove(stream);
|
|
||||||
stream.close().catchError(
|
|
||||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast a new or updated contact to all SSE clients
|
|
||||||
void broadcastContact(Contact contact) {
|
|
||||||
// Update contact list
|
|
||||||
_contacts[contact.publicKeyHex] = contact;
|
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
|
||||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
|
||||||
final deadStreams = <StreamController<String>>[];
|
|
||||||
|
|
||||||
for (final stream in _contactStreams) {
|
|
||||||
if (stream.isClosed) {
|
|
||||||
deadStreams.add(stream);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
stream.add(event);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
|
||||||
);
|
|
||||||
deadStreams.add(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove dead streams
|
|
||||||
for (final stream in deadStreams) {
|
|
||||||
_contactStreams.remove(stream);
|
|
||||||
stream.close().catchError(
|
|
||||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format SSE event
|
|
||||||
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
|
|
||||||
final jsonData = jsonEncode(data);
|
|
||||||
return 'event: $eventType\ndata: $jsonData\n\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Message to JSON
|
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
|
||||||
return {
|
|
||||||
'id': message.id,
|
|
||||||
'messageType': message.messageType.name,
|
|
||||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
|
|
||||||
'channelIdx': message.channelIdx,
|
|
||||||
'pathLen': message.pathLen,
|
|
||||||
'textType': message.textType.value,
|
|
||||||
'senderTimestamp': message.senderTimestamp,
|
|
||||||
'text': message.text,
|
|
||||||
'isSarMarker': message.isSarMarker,
|
|
||||||
'sarGpsCoordinates': message.sarGpsCoordinates != null
|
|
||||||
? {
|
|
||||||
'latitude': message.sarGpsCoordinates!.latitude,
|
|
||||||
'longitude': message.sarGpsCoordinates!.longitude,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
'sarNotes': message.sarNotes,
|
|
||||||
'sarCustomEmoji': message.sarCustomEmoji,
|
|
||||||
'sarColorIndex': message.sarColorIndex,
|
|
||||||
'receivedAt': message.receivedAt.toIso8601String(),
|
|
||||||
'senderName': message.senderName,
|
|
||||||
'deliveryStatus': message.deliveryStatus.name,
|
|
||||||
'expectedAckTag': message.expectedAckTag,
|
|
||||||
'suggestedTimeoutMs': message.suggestedTimeoutMs,
|
|
||||||
'roundTripTimeMs': message.roundTripTimeMs,
|
|
||||||
'deliveredAt': message.deliveredAt?.toIso8601String(),
|
|
||||||
'recipientPublicKey': message.recipientPublicKey?.toList(),
|
|
||||||
'retryAttempt': message.retryAttempt,
|
|
||||||
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
|
|
||||||
'usedFloodFallback': message.usedFloodFallback,
|
|
||||||
'isRead': message.isRead,
|
|
||||||
'echoCount': message.echoCount,
|
|
||||||
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
|
||||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
|
||||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
|
||||||
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
|
||||||
'isDrawing': message.isDrawing,
|
|
||||||
'drawingId': message.drawingId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Contact to JSON
|
|
||||||
Map<String, dynamic> _contactToJson(Contact contact) {
|
|
||||||
return {
|
|
||||||
'publicKey': contact.publicKey.toList(),
|
|
||||||
'publicKeyHex': contact.publicKeyHex,
|
|
||||||
'type': contact.type.value,
|
|
||||||
'flags': contact.flags,
|
|
||||||
'outPathLen': contact.outPathLen,
|
|
||||||
'outPath': contact.outPath.toList(),
|
|
||||||
'advName': contact.advName,
|
|
||||||
'lastAdvert': contact.lastAdvert,
|
|
||||||
'advLat': contact.advLat,
|
|
||||||
'advLon': contact.advLon,
|
|
||||||
'lastMod': contact.lastMod,
|
|
||||||
'telemetry': contact.telemetry != null
|
|
||||||
? {
|
|
||||||
'batteryPercentage': contact.telemetry!.batteryPercentage,
|
|
||||||
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
|
|
||||||
'temperature': contact.telemetry!.temperature,
|
|
||||||
'humidity': contact.telemetry!.humidity,
|
|
||||||
'pressure': contact.telemetry!.pressure,
|
|
||||||
'gpsLocation': contact.telemetry!.gpsLocation != null
|
|
||||||
? {
|
|
||||||
'latitude': contact.telemetry!.gpsLocation!.latitude,
|
|
||||||
'longitude': contact.telemetry!.gpsLocation!.longitude,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear message history
|
|
||||||
void clearMessageHistory() {
|
|
||||||
_messageHistory.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear contact list
|
|
||||||
void clearContacts() {
|
|
||||||
_contacts.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../utils/voice_message_parser.dart';
|
|
||||||
|
|
||||||
class VoiceCodecPreferences {
|
|
||||||
static const String _codecKey = 'voice_codec';
|
|
||||||
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
|
||||||
|
|
||||||
static Future<VoiceCodecKind> getCodec() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final raw = prefs.getString(_codecKey);
|
|
||||||
return VoiceCodecKind.values.firstWhere(
|
|
||||||
(codec) => codec.name == raw,
|
|
||||||
orElse: () => defaultCodec,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> setCodec(VoiceCodecKind codec) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(_codecKey, codec.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -3,24 +3,42 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
class ResolvedTraceNode {
|
class ResolvedTraceNode {
|
||||||
final MeshMapNode? node;
|
final List<MeshMapNode> candidates;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
final bool usedOnlineFallback;
|
final bool usedOnlineFallback;
|
||||||
|
final int selectedIndex;
|
||||||
|
|
||||||
const ResolvedTraceNode({
|
const ResolvedTraceNode({
|
||||||
required this.node,
|
required this.candidates,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
required this.usedOnlineFallback,
|
required this.usedOnlineFallback,
|
||||||
|
this.selectedIndex = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
MeshMapNode? get node =>
|
||||||
|
candidates.isEmpty ? null : candidates[selectedIndex];
|
||||||
bool get hasMatch => node != null;
|
bool get hasMatch => node != null;
|
||||||
bool get isAmbiguous => matchCount > 1;
|
bool get isAmbiguous => matchCount > 1;
|
||||||
|
bool get canCycle => candidates.length > 1;
|
||||||
|
|
||||||
String? get matchSummary {
|
String? get matchSummary {
|
||||||
if (matchCount <= 1) return null;
|
if (matchCount <= 1) return null;
|
||||||
final source = usedOnlineFallback ? 'online' : 'local';
|
final source = usedOnlineFallback ? 'online' : 'local';
|
||||||
return '$matchCount $source matches';
|
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 {
|
class TraceNodeResolver {
|
||||||
@@ -38,7 +56,7 @@ class TraceNodeResolver {
|
|||||||
}) {
|
}) {
|
||||||
if (prefixHex == null || prefixHex.isEmpty) {
|
if (prefixHex == null || prefixHex.isEmpty) {
|
||||||
return const ResolvedTraceNode(
|
return const ResolvedTraceNode(
|
||||||
node: null,
|
candidates: <MeshMapNode>[],
|
||||||
matchCount: 0,
|
matchCount: 0,
|
||||||
usedOnlineFallback: false,
|
usedOnlineFallback: false,
|
||||||
);
|
);
|
||||||
@@ -49,7 +67,7 @@ class TraceNodeResolver {
|
|||||||
.toList();
|
.toList();
|
||||||
if (allMatches.isEmpty) {
|
if (allMatches.isEmpty) {
|
||||||
return const ResolvedTraceNode(
|
return const ResolvedTraceNode(
|
||||||
node: null,
|
candidates: <MeshMapNode>[],
|
||||||
matchCount: 0,
|
matchCount: 0,
|
||||||
usedOnlineFallback: false,
|
usedOnlineFallback: false,
|
||||||
);
|
);
|
||||||
@@ -84,12 +102,93 @@ class TraceNodeResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return ResolvedTraceNode(
|
return ResolvedTraceNode(
|
||||||
node: pool.first,
|
candidates: List<MeshMapNode>.unmodifiable(pool),
|
||||||
matchCount: pool.length,
|
matchCount: pool.length,
|
||||||
usedOnlineFallback: usedOnlineFallback,
|
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(
|
static double _scoreNode(
|
||||||
MeshMapNode node, {
|
MeshMapNode node, {
|
||||||
LatLng? referenceA,
|
LatLng? referenceA,
|
||||||
@@ -108,6 +207,14 @@ class TraceNodeResolver {
|
|||||||
return double.maxFinite;
|
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) {
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
final ax = a.longitude;
|
final ax = a.longitude;
|
||||||
final ay = a.latitude;
|
final ay = a.latitude;
|
||||||
|
|||||||
@@ -11,36 +11,26 @@ 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
|
||||||
|
|
||||||
enum VoiceCodecKind {
|
|
||||||
codec2(0, 'Codec2');
|
|
||||||
|
|
||||||
const VoiceCodecKind(this.id, this.label);
|
|
||||||
final int id;
|
|
||||||
final String label;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identifies which voice codec/mode was used for a packet.
|
/// 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', VoiceCodecKind.codec2, 8000, 100, 1600),
|
mode700c(0, '700C', 8000, 100, 1600),
|
||||||
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
|
mode1200(1, '1200', 8000, 150, 1040),
|
||||||
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
|
mode2400(2, '2400', 8000, 300, 520),
|
||||||
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
|
mode1300(3, '1300', 8000, 175, 880),
|
||||||
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
|
mode1400(4, '1400', 8000, 175, 880),
|
||||||
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
|
mode1600(5, '1600', 8000, 200, 800),
|
||||||
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
|
mode3200(6, '3200', 8000, 400, 400);
|
||||||
|
|
||||||
const VoicePacketMode(
|
const VoicePacketMode(
|
||||||
this.id,
|
this.id,
|
||||||
this.label,
|
this.label,
|
||||||
this.codec,
|
|
||||||
this.sampleRateHz,
|
this.sampleRateHz,
|
||||||
this.bytesPerSecond,
|
this.bytesPerSecond,
|
||||||
this.packetDurationMs,
|
this.packetDurationMs,
|
||||||
);
|
);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
final VoiceCodecKind codec;
|
|
||||||
final int sampleRateHz;
|
final int sampleRateHz;
|
||||||
final int bytesPerSecond;
|
final int bytesPerSecond;
|
||||||
final int packetDurationMs;
|
final int packetDurationMs;
|
||||||
|
|||||||
175
lib/widgets/common/bidirectional_refresh.dart
Normal file
175
lib/widgets/common/bidirectional_refresh.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,8 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.purple;
|
return Colors.purple;
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green;
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.teal;
|
return Colors.teal;
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,8 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
return Icons.router;
|
return Icons.router;
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.meeting_room;
|
return Icons.meeting_room;
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors;
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public;
|
return Icons.public;
|
||||||
}
|
}
|
||||||
|
|||||||
106
lib/widgets/compact_signal_indicator.dart
Normal file
106
lib/widgets/compact_signal_indicator.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../providers/connection_provider.dart';
|
|
||||||
import '../models/sse_server_config.dart';
|
|
||||||
|
|
||||||
/// Connection Mode Selector Widget
|
|
||||||
///
|
|
||||||
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
|
|
||||||
class ConnectionModeSelector extends StatefulWidget {
|
|
||||||
const ConnectionModeSelector({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|
||||||
List<String> _localIPs = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadLocalIPs();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
|
||||||
if (kIsWeb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
final interfaces = await NetworkInterface.list();
|
|
||||||
for (final interface in interfaces) {
|
|
||||||
for (final addr in interface.addresses) {
|
|
||||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
|
||||||
ipsSet.add(addr.address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error getting network interfaces: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_localIPs = ipsSet.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final connectionProvider = Provider.of<ConnectionProvider>(context);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Section Header
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'Network Sharing',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// SSE Server Toggle
|
|
||||||
SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.share),
|
|
||||||
title: const Text('Share Device (Server)'),
|
|
||||||
subtitle: Text(
|
|
||||||
connectionProvider.isSseServerRunning
|
|
||||||
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
|
|
||||||
: 'Share BLE device with multiple clients over network',
|
|
||||||
),
|
|
||||||
value: connectionProvider.isSseServerRunning,
|
|
||||||
onChanged: (enabled) async {
|
|
||||||
if (enabled) {
|
|
||||||
// Start server with default config (port 12929, no auth)
|
|
||||||
final config = const SseServerConfig(port: 12929, enabled: true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connectionProvider.startSseServer(config);
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('SSE server started on port 12929'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to start server: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Stop server
|
|
||||||
await connectionProvider.stopSseServer();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// Show IP addresses when server is running
|
|
||||||
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
'Connect from other devices:',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
..._localIPs.map((ip) {
|
|
||||||
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: const Icon(Icons.wifi, size: 20),
|
|
||||||
title: Text(
|
|
||||||
url,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
|
||||||
),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Icons.copy, size: 20),
|
|
||||||
tooltip: 'Copy URL',
|
|
||||||
onPressed: () {
|
|
||||||
Clipboard.setData(ClipboardData(text: url));
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Copied $url'),
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
@@ -84,7 +81,7 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
try {
|
try {
|
||||||
final channelName = _nameController.text.trim();
|
final channelName = _nameController.text.trim();
|
||||||
final isHashChannel = channelName.startsWith('#');
|
final isHashChannel = channelName.startsWith('#');
|
||||||
|
|
||||||
// For hash channels, pass empty secret (will be auto-generated)
|
// For hash channels, pass empty secret (will be auto-generated)
|
||||||
// For private channels, use the provided secret
|
// For private channels, use the provided secret
|
||||||
final secret = isHashChannel ? '' : _secretController.text;
|
final secret = isHashChannel ? '' : _secretController.text;
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../models/path_history.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../services/contact_route_resolver.dart';
|
import '../../services/contact_route_resolver.dart';
|
||||||
import '../../services/path_history_service.dart';
|
import '../../services/path_history_service.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
import '../../models/path_history.dart';
|
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
final ParsedContactRoute? route;
|
final ParsedContactRoute? route;
|
||||||
@@ -53,19 +54,11 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
required Contact contact,
|
required Contact contact,
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<ContactRouteDialogResult>(
|
return Navigator.of(context).push<ContactRouteDialogResult>(
|
||||||
context: context,
|
MaterialPageRoute(
|
||||||
isScrollControlled: true,
|
builder: (context) => ContactRouteDialog(
|
||||||
showDragHandle: true,
|
contact: contact,
|
||||||
builder: (context) => SafeArea(
|
availableContacts: availableContacts,
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ContactRouteDialog(
|
|
||||||
contact: contact,
|
|
||||||
availableContacts: availableContacts,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
late final TextEditingController _relaySearchController;
|
||||||
final PathHistoryService _pathHistoryService = PathHistoryService();
|
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
bool _showRoutingInfo = false;
|
bool _showRoutingInfo = false;
|
||||||
|
bool _showManualEditor = false;
|
||||||
List<Contact> _selectedMapHops = const [];
|
List<Contact> _selectedMapHops = const [];
|
||||||
ContactPathHistory? _pathHistory;
|
ContactPathHistory? _pathHistory;
|
||||||
_RouteEntryMode _entryMode = _RouteEntryMode.map;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_controller = TextEditingController(
|
_controller = TextEditingController(
|
||||||
text: widget.contact.routeCanonicalText,
|
text: widget.contact.routeCanonicalText,
|
||||||
);
|
);
|
||||||
|
_relaySearchController = TextEditingController();
|
||||||
_controller.addListener(_reparse);
|
_controller.addListener(_reparse);
|
||||||
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
|
_showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
|
||||||
? _RouteEntryMode.manual
|
|
||||||
: _RouteEntryMode.map;
|
|
||||||
_loadHashSizePreference();
|
_loadHashSizePreference();
|
||||||
_loadPathHistory();
|
_loadPathHistory();
|
||||||
_reparse();
|
_reparse();
|
||||||
@@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_relaySearchController.dispose();
|
||||||
_controller
|
_controller
|
||||||
..removeListener(_reparse)
|
..removeListener(_reparse)
|
||||||
..dispose();
|
..dispose();
|
||||||
@@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.map;
|
_showManualEditor = false;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.manual;
|
_showManualEditor = true;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -416,30 +410,279 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPreviewSection() {
|
Widget _buildPreviewSection() {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null ? 'Route preview' : _parsedRoute!.summary,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null
|
||||||
|
? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
|
||||||
|
: '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (_parsedRoute != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SelectableText(
|
||||||
|
_parsedRoute!.canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedHopSection() {
|
||||||
|
if (_selectedMapHops.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relays selected yet. Add repeaters below or let auto resolve build a route.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
|
||||||
_parsedRoute == null
|
const SizedBox(height: 8),
|
||||||
? 'Preview: enter or pick a route to validate it.'
|
..._selectedMapHops.asMap().entries.map((entry) {
|
||||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
final index = entry.key;
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
final contact = entry.value;
|
||||||
),
|
return Card(
|
||||||
if (_parsedRoute != null) ...[
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
const SizedBox(height: 4),
|
child: ListTile(
|
||||||
SelectableText(
|
leading: CircleAvatar(child: Text('${index + 1}')),
|
||||||
_parsedRoute!.canonicalText,
|
title: Text(contact.displayName),
|
||||||
style: Theme.of(
|
subtitle: Text(
|
||||||
context,
|
_tokenFor(contact, _selectedHashSize),
|
||||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
style: Theme.of(
|
||||||
),
|
context,
|
||||||
],
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: 'Remove relay',
|
||||||
|
onPressed: () => _toggleHop(contact),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBuilderTab(
|
Widget _buildRelayPicker(List<Contact> routeCandidates) {
|
||||||
BuildContext context, {
|
final query = _relaySearchController.text.trim().toLowerCase();
|
||||||
|
final filteredCandidates = routeCandidates.where((contact) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(query) ||
|
||||||
|
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _relaySearchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
labelText: 'Find relay',
|
||||||
|
hintText: 'Search by name or token',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (filteredCandidates.isEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No visible relays match this search.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...filteredCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isSelected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(candidate.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
_tokenFor(candidate, _selectedHashSize),
|
||||||
|
if (location != null)
|
||||||
|
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||||
|
].join(' • '),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => _toggleHop(candidate),
|
||||||
|
child: Text(isSelected ? 'Remove' : 'Add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildManualEditor() {
|
||||||
|
return ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
childrenPadding: EdgeInsets.zero,
|
||||||
|
initiallyExpanded: _showManualEditor,
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
|
setState(() {
|
||||||
|
_showManualEditor = expanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: const Text('Manual route edit'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Use this when you need to paste or tweak hop tokens directly.',
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Route',
|
||||||
|
hintText: _selectedHashSize == 1
|
||||||
|
? 'AA,BB,CC'
|
||||||
|
: _selectedHashSize == 2
|
||||||
|
? 'AABB,CCDD'
|
||||||
|
: 'AABBCC,DDEEFF',
|
||||||
|
helperText:
|
||||||
|
'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.',
|
||||||
|
errorText: _errorText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMapPreview({
|
||||||
|
required List<Contact> routeCandidates,
|
||||||
|
required List<LatLng> mapPoints,
|
||||||
|
required List<LatLng> routePoints,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 220,
|
||||||
|
child: mapPoints.length < 2
|
||||||
|
? const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Map preview needs your advertised location, the contact location, and visible repeater locations.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
|
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
),
|
||||||
|
if (routePoints.length >= 2)
|
||||||
|
flutter_map.PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
flutter_map.Polyline(
|
||||||
|
points: routePoints,
|
||||||
|
strokeWidth: 4,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
...routeCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: LatLng(
|
||||||
|
candidate.displayLocation!.latitude,
|
||||||
|
candidate.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
width: 64,
|
||||||
|
height: 70,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _toggleHop(candidate),
|
||||||
|
child: _RouteMarkerDot(
|
||||||
|
label: _tokenFor(candidate, _selectedHashSize),
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.primary
|
||||||
|
: Colors.blueGrey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBuilderTab({
|
||||||
required List<Contact> routeCandidates,
|
required List<Contact> routeCandidates,
|
||||||
required List<LatLng> mapPoints,
|
required List<LatLng> mapPoints,
|
||||||
required List<LatLng> routePoints,
|
required List<LatLng> routePoints,
|
||||||
@@ -447,174 +690,43 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<_RouteEntryMode>(
|
Wrap(
|
||||||
segments: const [
|
spacing: 8,
|
||||||
ButtonSegment<_RouteEntryMode>(
|
runSpacing: 8,
|
||||||
value: _RouteEntryMode.map,
|
children: [
|
||||||
icon: Icon(Icons.map_outlined),
|
FilledButton.tonalIcon(
|
||||||
label: Text('Map'),
|
onPressed: _resolvePathAutomatically,
|
||||||
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
|
label: const Text('Auto resolve'),
|
||||||
),
|
),
|
||||||
ButtonSegment<_RouteEntryMode>(
|
OutlinedButton.icon(
|
||||||
value: _RouteEntryMode.manual,
|
onPressed: _selectedMapHops.isEmpty
|
||||||
icon: Icon(Icons.tune),
|
? null
|
||||||
label: Text('Manual'),
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedMapHops = const [];
|
||||||
|
_syncControllerFromSelectedHops();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.clear_all),
|
||||||
|
label: const Text('Clear relays'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
selected: {_entryMode},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
setState(() {
|
|
||||||
_entryMode = selection.first;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_entryMode == _RouteEntryMode.manual) ...[
|
_buildPreviewSection(),
|
||||||
TextField(
|
const SizedBox(height: 16),
|
||||||
controller: _controller,
|
_buildSelectedHopSection(),
|
||||||
textCapitalization: TextCapitalization.characters,
|
const SizedBox(height: 16),
|
||||||
decoration: InputDecoration(
|
_buildRelayPicker(routeCandidates),
|
||||||
labelText: 'Route',
|
const SizedBox(height: 16),
|
||||||
hintText: _selectedHashSize == 1
|
_buildMapPreview(
|
||||||
? 'AA,BB,CC'
|
routeCandidates: routeCandidates,
|
||||||
: _selectedHashSize == 2
|
mapPoints: mapPoints,
|
||||||
? 'AABB,CCDD'
|
routePoints: routePoints,
|
||||||
: 'AABBCC,DDEEFF',
|
),
|
||||||
helperText:
|
const SizedBox(height: 16),
|
||||||
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
_buildManualEditor(),
|
||||||
errorText: _errorText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
] else ...[
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _resolvePathAutomatically,
|
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
|
||||||
label: const Text('Resolve Path'),
|
|
||||||
),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
|
||||||
child: Text(
|
|
||||||
'Tap repeaters on the map to build the path, then review the generated route below.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SizedBox(
|
|
||||||
height: 260,
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: DecoratedBox(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
|
||||||
),
|
|
||||||
child: mapPoints.length < 2
|
|
||||||
? const Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: flutter_map.FlutterMap(
|
|
||||||
options: flutter_map.MapOptions(
|
|
||||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
|
||||||
bounds: flutter_map.LatLngBounds.fromPoints(
|
|
||||||
mapPoints,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
flutter_map.TileLayer(
|
|
||||||
urlTemplate:
|
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
userAgentPackageName: 'com.meshcore.sar',
|
|
||||||
),
|
|
||||||
if (routePoints.length >= 2)
|
|
||||||
flutter_map.PolylineLayer(
|
|
||||||
polylines: [
|
|
||||||
flutter_map.Polyline(
|
|
||||||
points: routePoints,
|
|
||||||
strokeWidth: 4,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
flutter_map.MarkerLayer(
|
|
||||||
markers: [
|
|
||||||
...routeCandidates.map((candidate) {
|
|
||||||
final isSelected = _selectedMapHops.any(
|
|
||||||
(item) =>
|
|
||||||
item.publicKeyHex ==
|
|
||||||
candidate.publicKeyHex,
|
|
||||||
);
|
|
||||||
return flutter_map.Marker(
|
|
||||||
point: LatLng(
|
|
||||||
candidate.displayLocation!.latitude,
|
|
||||||
candidate.displayLocation!.longitude,
|
|
||||||
),
|
|
||||||
width: 64,
|
|
||||||
height: 70,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => _toggleHop(candidate),
|
|
||||||
child: _RouteMarkerDot(
|
|
||||||
label: _tokenFor(
|
|
||||||
candidate,
|
|
||||||
_selectedHashSize,
|
|
||||||
),
|
|
||||||
color: isSelected
|
|
||||||
? Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary
|
|
||||||
: Colors.blueGrey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (_selectedMapHops.isNotEmpty)
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: _selectedMapHops.map((contact) {
|
|
||||||
return InputChip(
|
|
||||||
label: Text(contact.displayName),
|
|
||||||
onDeleted: () => _toggleHop(contact),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _controller,
|
|
||||||
readOnly: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Generated route',
|
|
||||||
helperText: 'Switch to Manual if you want to edit the hop list.',
|
|
||||||
errorText: _errorText,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
child: FractionallySizedBox(
|
child: Scaffold(
|
||||||
heightFactor: 0.85,
|
appBar: AppBar(
|
||||||
child: Padding(
|
title: Text('Set Path for ${widget.contact.displayName}'),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
bottom: const TabBar(
|
||||||
child: Column(
|
tabs: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Tab(text: 'Build'),
|
||||||
children: [
|
Tab(text: 'History'),
|
||||||
Text(
|
],
|
||||||
'Set Route for ${widget.contact.displayName}',
|
),
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
),
|
||||||
),
|
body: SafeArea(
|
||||||
const SizedBox(height: 8),
|
child: Padding(
|
||||||
Text(
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
'Choose how to build the route, or reuse one from history.',
|
child: Column(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text(
|
||||||
const TabBar(
|
'Plan the route on its own screen, then save it once the preview looks right.',
|
||||||
tabs: [
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
Tab(text: 'Build'),
|
),
|
||||||
Tab(text: 'History'),
|
const SizedBox(height: 16),
|
||||||
],
|
Expanded(
|
||||||
),
|
child: TabBarView(
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
Expanded(
|
ListView(
|
||||||
child: TabBarView(
|
|
||||||
children: [
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
_buildBuilderTab(
|
_buildBuilderTab(
|
||||||
context,
|
|
||||||
routeCandidates: routeCandidates,
|
routeCandidates: routeCandidates,
|
||||||
mapPoints: mapPoints,
|
mapPoints: mapPoints,
|
||||||
routePoints: routePoints,
|
routePoints: routePoints,
|
||||||
@@ -772,44 +878,54 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
clearPathOnMaxRetry:
|
clearPathOnMaxRetry:
|
||||||
appProvider.clearPathOnMaxRetry,
|
appProvider.clearPathOnMaxRetry,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
ListView(
|
||||||
SingleChildScrollView(child: _buildHistoryTab()),
|
children: [
|
||||||
],
|
_buildHistoryTab(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
OverflowBar(
|
),
|
||||||
alignment: MainAxisAlignment.spaceBetween,
|
),
|
||||||
spacing: 8,
|
),
|
||||||
overflowSpacing: 8,
|
bottomNavigationBar: SafeArea(
|
||||||
children: [
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: OverflowBar(
|
||||||
|
alignment: MainAxisAlignment.spaceBetween,
|
||||||
|
spacing: 8,
|
||||||
|
overflowSpacing: 8,
|
||||||
|
children: [
|
||||||
|
if (widget.contact.routeHasPath)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(
|
||||||
child: const Text('Cancel'),
|
context,
|
||||||
),
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
if (widget.contact.routeHasPath)
|
child: const Text('Clear Route'),
|
||||||
TextButton(
|
)
|
||||||
onPressed: () => Navigator.of(
|
else
|
||||||
context,
|
const SizedBox.shrink(),
|
||||||
).pop(const ContactRouteDialogResult.clear()),
|
FilledButton(
|
||||||
child: const Text('Clear Route'),
|
onPressed: _parsedRoute == null
|
||||||
),
|
? null
|
||||||
FilledButton(
|
: () => Navigator.of(context).pop(
|
||||||
onPressed: _parsedRoute == null
|
ContactRouteDialogResult.setWithFallback(
|
||||||
? null
|
_parsedRoute!,
|
||||||
: () => Navigator.of(context).pop(
|
inferredFallbackLocation:
|
||||||
ContactRouteDialogResult.setWithFallback(
|
_buildSyntheticFallbackLocation(),
|
||||||
_parsedRoute!,
|
|
||||||
inferredFallbackLocation:
|
|
||||||
_buildSyntheticFallbackLocation(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Set Route'),
|
),
|
||||||
),
|
child: const Text('Save Path'),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryMode { map, manual }
|
|
||||||
|
|
||||||
class _RouteMarkerDot extends StatelessWidget {
|
class _RouteMarkerDot extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import 'contact_route_dialog.dart';
|
|||||||
import 'contact_trace_sheet.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 '../sensors/sensor_telemetry_card.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ContactTile extends StatelessWidget {
|
class ContactTile extends StatelessWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
final String? groupLabel;
|
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;
|
||||||
@@ -32,6 +34,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.contact,
|
required this.contact,
|
||||||
this.groupLabel,
|
this.groupLabel,
|
||||||
|
this.compact = false,
|
||||||
this.currentPosition,
|
this.currentPosition,
|
||||||
this.calculateDistance,
|
this.calculateDistance,
|
||||||
this.formatDistance,
|
this.formatDistance,
|
||||||
@@ -127,50 +130,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(
|
||||||
const SizedBox(height: 4),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Wrap(
|
children: [
|
||||||
spacing: 6,
|
const SizedBox(height: 4),
|
||||||
runSpacing: 6,
|
Wrap(
|
||||||
children: [
|
spacing: 6,
|
||||||
if (groupLabel case final label?)
|
runSpacing: 6,
|
||||||
_buildMetaPill(
|
children: [
|
||||||
context,
|
if (groupLabel case final label?)
|
||||||
icon: Icons.folder_copy_outlined,
|
_buildMetaPill(
|
||||||
label: label,
|
context,
|
||||||
|
icon: Icons.folder_copy_outlined,
|
||||||
|
label: label,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_buildMetaPill(
|
if (location != null) ...[
|
||||||
context,
|
const SizedBox(height: 2),
|
||||||
icon: Icons.key_outlined,
|
_buildLocationLine(
|
||||||
label: contact.publicKeyShort,
|
context,
|
||||||
monospace: true,
|
latitude: location.latitude,
|
||||||
),
|
longitude: location.longitude,
|
||||||
],
|
distanceText: distanceText,
|
||||||
),
|
),
|
||||||
if (location != null) ...[
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 2),
|
Row(children: [_buildRoutePill(context, contact)]),
|
||||||
_buildLocationLine(
|
] else
|
||||||
context,
|
Padding(
|
||||||
latitude: location.latitude,
|
padding: const EdgeInsets.only(top: 4),
|
||||||
longitude: location.longitude,
|
child: Text(
|
||||||
distanceText: distanceText,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
),
|
style: Theme.of(
|
||||||
const SizedBox(height: 6),
|
context,
|
||||||
Row(children: [_buildRoutePill(context, contact)]),
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
] 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),
|
||||||
@@ -294,6 +293,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);
|
||||||
}
|
}
|
||||||
@@ -305,10 +326,15 @@ 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 ||
|
||||||
|
contact.type == ContactType.sensor;
|
||||||
final canAddToSensors =
|
final canAddToSensors =
|
||||||
contact.type == ContactType.chat ||
|
contact.type == ContactType.chat ||
|
||||||
contact.type == ContactType.repeater;
|
contact.type == ContactType.repeater ||
|
||||||
|
contact.type == ContactType.sensor;
|
||||||
|
final canPreviewSensor = contact.isSensor;
|
||||||
final sensorsProvider = context.read<SensorsProvider>();
|
final sensorsProvider = context.read<SensorsProvider>();
|
||||||
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
|
||||||
|
|
||||||
@@ -318,109 +344,178 @@ 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.isChannel)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.route),
|
|
||||||
title: const Text('Trace'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(sheetContext);
|
|
||||||
_showTraceSheet(context, contact);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (!contact.isPublicChannel)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.delete, color: Colors.red),
|
|
||||||
title: Text(
|
|
||||||
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
),
|
||||||
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 (canPreviewSensor)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.visibility_outlined),
|
||||||
|
title: const Text('Preview'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (contact.isChannel) {
|
await _showSensorPreviewSheet(context, contact);
|
||||||
_showDeleteChannelDialog(context, contact);
|
},
|
||||||
} else {
|
),
|
||||||
_showDeleteConfirmation(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;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (contact.isChannel) {
|
||||||
|
_showDeleteChannelDialog(context, contact);
|
||||||
|
} else {
|
||||||
|
_showDeleteConfirmation(context, contact);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showSensorPreviewSheet(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact,
|
||||||
|
) async {
|
||||||
|
final publicKeyHex = contact.publicKeyHex;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) => Consumer2<ContactsProvider, SensorsProvider>(
|
||||||
|
builder: (context, contactsProvider, sensorsProvider, child) {
|
||||||
|
Contact? liveContact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
liveContact = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final previewContact = liveContact ?? contact;
|
||||||
|
final visibleFields = sensorMetricKeysFor(previewContact);
|
||||||
|
final fieldOrder = sensorsProvider.metricOrderFor(
|
||||||
|
publicKeyHex,
|
||||||
|
visibleFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
child: SensorTelemetryCard(
|
||||||
|
contact: previewContact,
|
||||||
|
state: sensorsProvider.stateFor(publicKeyHex),
|
||||||
|
visibleFields: visibleFields,
|
||||||
|
fieldOrder: fieldOrder,
|
||||||
|
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
emptyMetricsMessage: 'No telemetry fields available yet.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showContactOnMap(BuildContext context, Contact contact) {
|
void _showContactOnMap(BuildContext context, Contact contact) {
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
@@ -555,6 +650,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,
|
||||||
@@ -914,3 +1043,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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ContactTraceSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
||||||
late final Future<_ContactTraceResult> _future;
|
late final Future<_ContactTraceResult> _future;
|
||||||
|
_ContactTraceResult? _traceOverride;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -89,7 +90,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final trace = snapshot.data!;
|
final trace = _traceOverride ?? snapshot.data!;
|
||||||
final routeEntries = _displayRouteEntries(trace);
|
final routeEntries = _displayRouteEntries(trace);
|
||||||
final concreteNodes = routeEntries
|
final concreteNodes = routeEntries
|
||||||
.where((entry) => entry.resolved.node != null)
|
.where((entry) => entry.resolved.node != null)
|
||||||
@@ -99,7 +100,9 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
.map((node) => LatLng(node.latitude, node.longitude))
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
.toList();
|
.toList();
|
||||||
final hasMapPath = mapPoints.length >= 2;
|
final hasMapPath = mapPoints.length >= 2;
|
||||||
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>();
|
final relayNodes = trace.matchedRelayNodes
|
||||||
|
.map((entry) => entry.node)
|
||||||
|
.whereType<MeshMapNode>();
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
@@ -128,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
trace.routeHashes.isEmpty
|
trace.routeHashes.isEmpty
|
||||||
? 'Direct route to ${widget.contact.displayName}'
|
? 'No relay path saved for ${widget.contact.displayName}'
|
||||||
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
@@ -196,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
entry.key == 0
|
Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key ==
|
|
||||||
concreteNodes
|
|
||||||
.length -
|
|
||||||
1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -233,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -249,13 +245,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
),
|
),
|
||||||
...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(
|
||||||
@@ -267,8 +268,11 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
),
|
),
|
||||||
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}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
|
trailing: entry.value.resolved.canCycle
|
||||||
|
? const Icon(Icons.sync_alt)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -311,33 +315,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||||
final entries = <_RouteDisplayEntry>[];
|
return trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||||
if (trace.sender.node != null) {
|
final resolved = entry.value;
|
||||||
entries.add(_RouteDisplayEntry.fromResolved(trace.sender));
|
final node = resolved.node;
|
||||||
}
|
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||||
entries.addAll(
|
return _RouteDisplayEntry(
|
||||||
trace.matchedRelayNodes.asMap().entries.map((entry) {
|
resolved: resolved,
|
||||||
final resolved = entry.value;
|
label: node?.name ?? 'Unknown',
|
||||||
final node = resolved.node;
|
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
||||||
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
matchSummary: resolved.matchSummary,
|
||||||
return _RouteDisplayEntry(
|
target: _RouteEntryTarget.relayNode(entry.key),
|
||||||
resolved: resolved,
|
);
|
||||||
label: node?.name ?? 'Unknown',
|
}).toList();
|
||||||
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
|
|
||||||
matchSummary: resolved.matchSummary,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (trace.recipient.node != null) {
|
|
||||||
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient));
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _routeRoleLabel(int index, int total) {
|
|
||||||
if (index == 0) return 'Sender';
|
|
||||||
if (index == total - 1) return 'Recipient';
|
|
||||||
return 'Relay';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prefixKeyLabel(String publicKey) =>
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
@@ -394,12 +383,17 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
|
||||||
|
nodes: matchedRelayNodes,
|
||||||
|
startNode: senderNode.node,
|
||||||
|
endNode: recipientNode.node,
|
||||||
|
);
|
||||||
|
|
||||||
return _ContactTraceResult(
|
return _ContactTraceResult(
|
||||||
sender: senderNode,
|
sender: senderNode,
|
||||||
recipient: recipientNode,
|
recipient: recipientNode,
|
||||||
routeHashes: routeHashes,
|
routeHashes: routeHashes,
|
||||||
matchedRelayNodes: matchedRelayNodes,
|
matchedRelayNodes: alignedRelayNodes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +487,20 @@ class _ContactTraceResult {
|
|||||||
required this.routeHashes,
|
required this.routeHashes,
|
||||||
required this.matchedRelayNodes,
|
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 {
|
class _RouteDisplayEntry {
|
||||||
@@ -500,26 +508,27 @@ class _RouteDisplayEntry {
|
|||||||
final String label;
|
final String label;
|
||||||
final String? keyLabel;
|
final String? keyLabel;
|
||||||
final String? matchSummary;
|
final String? matchSummary;
|
||||||
|
final _RouteEntryTarget target;
|
||||||
|
|
||||||
const _RouteDisplayEntry({
|
const _RouteDisplayEntry({
|
||||||
required this.resolved,
|
required this.resolved,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.keyLabel,
|
required this.keyLabel,
|
||||||
required this.matchSummary,
|
required this.matchSummary,
|
||||||
|
required this.target,
|
||||||
});
|
});
|
||||||
|
|
||||||
MeshMapNode? get node => resolved.node;
|
MeshMapNode? get node => resolved.node;
|
||||||
|
}
|
||||||
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
|
|
||||||
final node = resolved.node!;
|
enum _RouteEntryKind { relayNode }
|
||||||
return _RouteDisplayEntry(
|
|
||||||
resolved: resolved,
|
class _RouteEntryTarget {
|
||||||
label: node.name,
|
final _RouteEntryKind kind;
|
||||||
keyLabel: node.publicKey.substring(
|
final int index;
|
||||||
0,
|
|
||||||
math.min(12, node.publicKey.length),
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
),
|
|
||||||
matchSummary: resolved.matchSummary,
|
const _RouteEntryTarget.relayNode(int index)
|
||||||
);
|
: this._(_RouteEntryKind.relayNode, index);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
|||||||
try {
|
try {
|
||||||
// Manually add the room contact to the radio's flash storage
|
// Manually add the room contact to the radio's flash storage
|
||||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||||
|
final addError = connectionProvider.error;
|
||||||
|
if (addError != null) {
|
||||||
|
throw Exception(addError);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget {
|
|||||||
// Split contacts by type
|
// Split contacts by type
|
||||||
final persons = <Map<String, dynamic>>[];
|
final persons = <Map<String, dynamic>>[];
|
||||||
final repeaters = <Map<String, dynamic>>[];
|
final repeaters = <Map<String, dynamic>>[];
|
||||||
|
final sensors = <Map<String, dynamic>>[];
|
||||||
final rooms = <Map<String, dynamic>>[];
|
final rooms = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
// Calculate bearings and distances for each contact
|
// Calculate bearings and distances for each contact
|
||||||
@@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget {
|
|||||||
|
|
||||||
if (contact.isRepeater) {
|
if (contact.isRepeater) {
|
||||||
repeaters.add(item);
|
repeaters.add(item);
|
||||||
|
} else if (contact.isSensor) {
|
||||||
|
sensors.add(item);
|
||||||
} else if (contact.isRoom) {
|
} else if (contact.isRoom) {
|
||||||
rooms.add(item);
|
rooms.add(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort each list by distance
|
// Sort each list by distance
|
||||||
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
persons.sort(
|
||||||
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
|
);
|
||||||
|
repeaters.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
sensors.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
rooms.sort(
|
||||||
|
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
|
||||||
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.teamMembers,
|
l10n.teamMembers,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...persons.map((item) => _buildContactTile(
|
...persons.map(
|
||||||
|
(item) => _buildContactTile(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
Icons.groups,
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (showContacts && sensors.isNotEmpty) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
|
child: Text(
|
||||||
|
'Sensors',
|
||||||
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
item,
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
Icons.groups,
|
),
|
||||||
Theme.of(context).colorScheme.primary,
|
),
|
||||||
)),
|
...sensors.map(
|
||||||
|
(item) =>
|
||||||
|
_buildContactTile(context, item, Icons.sensors, Colors.green),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
// Repeaters section
|
// Repeaters section
|
||||||
if (showRepeaters && repeaters.isNotEmpty) ...[
|
if (showRepeaters && repeaters.isNotEmpty) ...[
|
||||||
@@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.repeaters,
|
l10n.repeaters,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...repeaters.map((item) => _buildContactTile(
|
...repeaters.map(
|
||||||
context,
|
(item) =>
|
||||||
item,
|
_buildContactTile(context, item, Icons.router, Colors.purple),
|
||||||
Icons.router,
|
),
|
||||||
Colors.purple,
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
// Rooms section
|
// Rooms section
|
||||||
if (rooms.isNotEmpty) ...[
|
if (rooms.isNotEmpty) ...[
|
||||||
@@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.rooms,
|
l10n.rooms,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...rooms.map(
|
||||||
|
(item) => _buildContactTile(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
Icons.meeting_room,
|
||||||
|
Colors.teal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...rooms.map((item) => _buildContactTile(
|
|
||||||
context,
|
|
||||||
item,
|
|
||||||
Icons.meeting_room,
|
|
||||||
Colors.teal,
|
|
||||||
)),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -161,24 +190,14 @@ class CompassContactList extends StatelessWidget {
|
|||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: selectedContact == contact
|
border: selectedContact == contact
|
||||||
? Border.all(
|
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
width: 2,
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: contact.roleEmoji != null
|
leading: contact.roleEmoji != null
|
||||||
? Text(
|
? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
|
||||||
contact.roleEmoji!,
|
: Icon(defaultIcon, color: iconColor, size: 24),
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
defaultIcon,
|
|
||||||
color: iconColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
title: Text(contact.displayName),
|
title: Text(contact.displayName),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
|
||||||
@@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${bearing.round()}°',
|
'${bearing.round()}°',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.bold,
|
context,
|
||||||
),
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
if (heading != null)
|
if (heading != null)
|
||||||
Text(
|
Text(
|
||||||
_formatRelativeBearing(bearing, heading!, context),
|
_formatRelativeBearing(bearing, heading!, context),
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(
|
||||||
color: Colors.grey,
|
context,
|
||||||
),
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate bearing between two points (in degrees)
|
// Calculate bearing between two points (in degrees)
|
||||||
double _calculateBearing(
|
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
final lat1Rad = lat1 * pi / 180;
|
final lat1Rad = lat1 * pi / 180;
|
||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget {
|
|||||||
|
|
||||||
// Calculate distance between two points (in meters)
|
// Calculate distance between two points (in meters)
|
||||||
double _calculateDistance(
|
double _calculateDistance(
|
||||||
double lat1, double lon1, double lat2, double lon2) {
|
double lat1,
|
||||||
|
double lon1,
|
||||||
|
double lat2,
|
||||||
|
double lon2,
|
||||||
|
) {
|
||||||
const R = 6371000; // Earth's radius in meters
|
const R = 6371000; // Earth's radius in meters
|
||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -262,7 +285,11 @@ class CompassContactList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
|
String _formatRelativeBearing(
|
||||||
|
double bearing,
|
||||||
|
double heading,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
// Calculate relative bearing (how much to turn from current heading)
|
// Calculate relative bearing (how much to turn from current heading)
|
||||||
double relative = bearing - heading;
|
double relative = bearing - heading;
|
||||||
|
|||||||
@@ -12,98 +12,107 @@ class MapMarkers {
|
|||||||
Function(Contact)? onContactTap,
|
Function(Contact)? onContactTap,
|
||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
final location = contact.displayLocation;
|
.map((contact) {
|
||||||
if (location == null) return null;
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
|
||||||
return Marker(
|
return Marker(
|
||||||
point: location,
|
point: location,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotate: false, // Don't rotate the entire marker with map
|
rotate: false, // Don't rotate the entire marker with map
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: -mapRotation * 3.14159265359 / 180,
|
angle: -mapRotation * 3.14159265359 / 180,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (onContactTap != null) {
|
if (onContactTap != null) {
|
||||||
onContactTap(contact);
|
onContactTap(contact);
|
||||||
} else {
|
} else {
|
||||||
_showContactInfo(context, contact);
|
_showContactInfo(context, contact);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 4,
|
||||||
color: _getLocationAgeColor(contact),
|
vertical: 1,
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
contact.timeSinceLocationUpdate,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
// Marker icon or emoji
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _getContactTypeColor(contact, context),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
),
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
),
|
color: _getLocationAgeColor(contact),
|
||||||
padding: const EdgeInsets.all(6),
|
borderRadius: BorderRadius.circular(3),
|
||||||
child: contact.roleEmoji != null
|
),
|
||||||
? Text(
|
child: Text(
|
||||||
contact.roleEmoji!,
|
contact.timeSinceLocationUpdate,
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_getContactTypeIcon(contact),
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 18,
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
// Name label (without emoji)
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 80),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 2),
|
||||||
maxLines: 1,
|
// Marker icon or emoji
|
||||||
textAlign: TextAlign.center,
|
Container(
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
|
color: _getContactTypeColor(contact, context),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: contact.roleEmoji != null
|
||||||
|
? Text(
|
||||||
|
contact.roleEmoji!,
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_getContactTypeIcon(contact),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Name label (without emoji)
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
})
|
||||||
);
|
.whereType<Marker>()
|
||||||
}).whereType<Marker>().toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Marker> createSarMarkers(
|
static List<Marker> createSarMarkers(
|
||||||
@@ -133,7 +142,10 @@ class MapMarkers {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _getSarMarkerColor(marker),
|
color: _getSarMarkerColor(marker),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -164,7 +176,7 @@ class MapMarkers {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.emoji, // Use custom emoji if available
|
marker.emoji, // Use custom emoji if available
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -172,7 +184,10 @@ class MapMarkers {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -183,8 +198,12 @@ class MapMarkers {
|
|||||||
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
|
||||||
debugPrint(' marker.notes: "${marker.notes}"');
|
debugPrint(' marker.notes: "${marker.notes}"');
|
||||||
debugPrint(' marker.type: ${marker.type}');
|
debugPrint(' marker.type: ${marker.type}');
|
||||||
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
|
debugPrint(
|
||||||
debugPrint(' marker.displayName: ${marker.displayName}');
|
' marker.type.displayName: ${marker.type.displayName}',
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
' marker.displayName: ${marker.displayName}',
|
||||||
|
);
|
||||||
|
|
||||||
return Text(
|
return Text(
|
||||||
marker.displayName,
|
marker.displayName,
|
||||||
@@ -241,17 +260,25 @@ class MapMarkers {
|
|||||||
_InfoRow(
|
_InfoRow(
|
||||||
'Voltage',
|
'Voltage',
|
||||||
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
|
||||||
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
|
||||||
)
|
)
|
||||||
else if (contact.displayBattery != null)
|
else if (contact.displayBattery != null)
|
||||||
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
|
||||||
if (contact.telemetry?.temperature != null)
|
if (contact.telemetry?.temperature != null)
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
|
'Temperature',
|
||||||
|
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
if (contact.telemetry?.humidity != null)
|
if (contact.telemetry?.humidity != null)
|
||||||
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
|
_InfoRow(
|
||||||
|
'Humidity',
|
||||||
|
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
|
||||||
|
),
|
||||||
if (contact.telemetry?.pressure != null)
|
if (contact.telemetry?.pressure != null)
|
||||||
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'),
|
_InfoRow(
|
||||||
|
'Pressure',
|
||||||
|
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
|
||||||
|
),
|
||||||
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
_InfoRow('Last Seen', contact.timeSinceLastSeen),
|
||||||
_InfoRow('Public Key', contact.publicKeyShort),
|
_InfoRow('Public Key', contact.publicKeyShort),
|
||||||
],
|
],
|
||||||
@@ -272,7 +299,10 @@ class MapMarkers {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
|
Text(
|
||||||
|
marker.emoji,
|
||||||
|
style: const TextStyle(fontSize: 24),
|
||||||
|
), // Use custom emoji if available
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Text(marker.displayName)),
|
Expanded(child: Text(marker.displayName)),
|
||||||
],
|
],
|
||||||
@@ -313,7 +343,9 @@ class MapMarkers {
|
|||||||
|
|
||||||
static Color _getSarMarkerColor(SarMarker marker) {
|
static Color _getSarMarkerColor(SarMarker marker) {
|
||||||
// If marker has a color index, use it (new format)
|
// If marker has a color index, use it (new format)
|
||||||
if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) {
|
if (marker.colorIndex != null &&
|
||||||
|
marker.colorIndex! >= 0 &&
|
||||||
|
marker.colorIndex! < 8) {
|
||||||
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
|
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
|
||||||
final hexCode = colorHex.replaceAll('#', '');
|
final hexCode = colorHex.replaceAll('#', '');
|
||||||
return Color(int.parse('FF$hexCode', radix: 16));
|
return Color(int.parse('FF$hexCode', radix: 16));
|
||||||
@@ -342,6 +374,8 @@ class MapMarkers {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -357,6 +391,8 @@ class MapMarkers {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: Text(value)),
|
||||||
child: Text(value),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -576,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 ?? '-'}',
|
||||||
@@ -846,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,
|
||||||
|
|||||||
@@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart';
|
|||||||
import '../../utils/trace_node_resolver.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();
|
||||||
@@ -27,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() {
|
||||||
@@ -39,20 +55,26 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
final preferredHashSize = await RouteHashPreferences.getHashSize();
|
||||||
final storedPath = messagesProvider
|
List<int>? packetPath = widget.packetPathOverride;
|
||||||
.getMessageReceptionDetails(widget.message.id)
|
String? senderPrefix;
|
||||||
?.pathBytes;
|
String? recipientPrefix;
|
||||||
final packetPath = (storedPath != null && storedPath.isNotEmpty)
|
|
||||||
? storedPath
|
|
||||||
: _extractPathFromPacketLogs(
|
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
|
||||||
message: widget.message,
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
|
||||||
@@ -66,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;
|
||||||
}
|
}
|
||||||
@@ -115,7 +139,7 @@ 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.resolved.node != null)
|
.where((entry) => entry.resolved.node != null)
|
||||||
@@ -153,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,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(
|
||||||
@@ -259,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(
|
||||||
@@ -293,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}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
|
trailing: entry.value.resolved.canCycle
|
||||||
|
? const Icon(Icons.sync_alt)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -344,6 +371,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -353,22 +383,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||||
final pathNodes = trace.matchedPathNodes
|
|
||||||
.map((entry) => entry.node)
|
|
||||||
.whereType<MeshMapNode>()
|
|
||||||
.toList();
|
|
||||||
if (pathNodes.isEmpty) {
|
|
||||||
return [
|
|
||||||
if (trace.sender.node != null)
|
|
||||||
_RouteDisplayEntry.fromResolved(trace.sender),
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(trace.recipient),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
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(
|
||||||
resolved: entry.value,
|
resolved: entry.value,
|
||||||
@@ -377,40 +393,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
? _prefixKeyLabel(entry.value.node!.publicKey)
|
? _prefixKeyLabel(entry.value.node!.publicKey)
|
||||||
: hashHex,
|
: hashHex,
|
||||||
matchSummary: entry.value.matchSummary,
|
matchSummary: entry.value.matchSummary,
|
||||||
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
final lastKey = pathNodes.last.publicKey;
|
|
||||||
return [
|
|
||||||
...entries,
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(trace.recipient),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstKey = pathNodes.first.publicKey;
|
return trace.matchedPathNodes
|
||||||
final lastKey = pathNodes.last.publicKey;
|
.asMap()
|
||||||
return [
|
.entries
|
||||||
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
|
.where((entry) => entry.value.node != null)
|
||||||
_RouteDisplayEntry.fromResolved(trace.sender),
|
.map(
|
||||||
...trace.matchedPathNodes
|
(entry) => _RouteDisplayEntry.fromResolved(
|
||||||
.where((entry) => entry.node != null)
|
entry.value,
|
||||||
.map(_RouteDisplayEntry.fromResolved),
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
if (trace.recipient.node != null &&
|
),
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
)
|
||||||
_RouteDisplayEntry.fromResolved(trace.recipient),
|
.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;
|
||||||
@@ -486,7 +489,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||||
packetPath,
|
packetPath,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
).reversed.toList();
|
);
|
||||||
final matched = _matchNodesFromPathHashes(
|
final matched = _matchNodesFromPathHashes(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
localPublicKeys: localPublicKeys,
|
localPublicKeys: localPublicKeys,
|
||||||
@@ -496,12 +499,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
senderLatLng: senderLatLng,
|
senderLatLng: senderLatLng,
|
||||||
recipientLatLng: recipientLatLng,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,13 +517,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
sender: senderNode.node,
|
sender: senderNode.node,
|
||||||
recipient: recipientNode.node,
|
recipient: recipientNode.node,
|
||||||
relayCount: math.max(0, widget.message.pathLen),
|
relayCount: math.max(0, widget.message!.pathLen),
|
||||||
);
|
);
|
||||||
final matchedPathNodes = <ResolvedTraceNode>[
|
final matchedPathNodes = <ResolvedTraceNode>[
|
||||||
if (senderNode.node != null) senderNode,
|
if (senderNode.node != null) senderNode,
|
||||||
...inferred.map(
|
...inferred.map(
|
||||||
(node) => ResolvedTraceNode(
|
(node) => ResolvedTraceNode(
|
||||||
node: node,
|
candidates: [node],
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
usedOnlineFallback: false,
|
usedOnlineFallback: false,
|
||||||
),
|
),
|
||||||
@@ -688,6 +696,21 @@ 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 {
|
||||||
@@ -695,17 +718,22 @@ class _RouteDisplayEntry {
|
|||||||
final String label;
|
final String label;
|
||||||
final String? keyLabel;
|
final String? keyLabel;
|
||||||
final String? matchSummary;
|
final String? matchSummary;
|
||||||
|
final _RouteEntryTarget target;
|
||||||
|
|
||||||
const _RouteDisplayEntry({
|
const _RouteDisplayEntry({
|
||||||
required this.resolved,
|
required this.resolved,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.keyLabel,
|
required this.keyLabel,
|
||||||
required this.matchSummary,
|
required this.matchSummary,
|
||||||
|
required this.target,
|
||||||
});
|
});
|
||||||
|
|
||||||
MeshMapNode? get node => resolved.node;
|
MeshMapNode? get node => resolved.node;
|
||||||
|
|
||||||
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
|
factory _RouteDisplayEntry.fromResolved(
|
||||||
|
ResolvedTraceNode resolved, {
|
||||||
|
required _RouteEntryTarget target,
|
||||||
|
}) {
|
||||||
final node = resolved.node!;
|
final node = resolved.node!;
|
||||||
return _RouteDisplayEntry(
|
return _RouteDisplayEntry(
|
||||||
resolved: resolved,
|
resolved: resolved,
|
||||||
@@ -715,6 +743,19 @@ class _RouteDisplayEntry {
|
|||||||
math.min(12, node.publicKey.length),
|
math.min(12, node.publicKey.length),
|
||||||
),
|
),
|
||||||
matchSummary: resolved.matchSummary,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
1866
lib/widgets/sensors/sensor_telemetry_card.dart
Normal file
1866
lib/widgets/sensors/sensor_telemetry_card.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
28
pubspec.lock
28
pubspec.lock
@@ -631,14 +631,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
http_methods:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: http_methods
|
|
||||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -795,8 +787,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: f600789fac7f743b1c7db8aa24e441405413f669
|
||||||
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
|
resolved-ref: f600789fac7f743b1c7db8aa24e441405413f669
|
||||||
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"
|
||||||
@@ -1208,22 +1200,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
shelf:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shelf
|
|
||||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.2"
|
|
||||||
shelf_router:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shelf_router
|
|
||||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.4"
|
|
||||||
simple_sparse_list:
|
simple_sparse_list:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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.0312.1+27
|
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: "f600789fac7f743b1c7db8aa24e441405413f669"
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
@@ -118,10 +118,6 @@ dependencies:
|
|||||||
# XML parsing for GPX import/export
|
# XML parsing for GPX import/export
|
||||||
xml: ^6.5.0
|
xml: ^6.5.0
|
||||||
|
|
||||||
# SSE web server for multi-user support
|
|
||||||
shelf: ^1.4.0
|
|
||||||
shelf_router: ^1.1.0
|
|
||||||
|
|
||||||
# Network Service Discovery (Bonjour/mDNS)
|
# Network Service Discovery (Bonjour/mDNS)
|
||||||
nsd: ^4.0.3
|
nsd: ^4.0.3
|
||||||
|
|
||||||
|
|||||||
148
test/providers/app_provider_channel_info_test.dart
Normal file
148
test/providers/app_provider_channel_info_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
86
test/providers/connection_provider_channel_slot_test.dart
Normal file
86
test/providers/connection_provider_channel_slot_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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';
|
||||||
@@ -141,6 +142,7 @@ void main() {
|
|||||||
final contactTypes = <ContactType>[
|
final contactTypes = <ContactType>[
|
||||||
ContactType.chat,
|
ContactType.chat,
|
||||||
ContactType.repeater,
|
ContactType.repeater,
|
||||||
|
ContactType.sensor,
|
||||||
ContactType.room,
|
ContactType.room,
|
||||||
ContactType.channel,
|
ContactType.channel,
|
||||||
];
|
];
|
||||||
@@ -427,6 +429,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', () {
|
||||||
@@ -733,5 +775,45 @@ void main() {
|
|||||||
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
|
||||||
expect(restored.savedGroupsForSection('rooms').first.label, '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('');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
194
test/providers/sensors_provider_test.dart
Normal file
194
test/providers/sensors_provider_test.dart
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/device_info.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/sensors_provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class _FakeContactsProvider extends ContactsProvider {
|
||||||
|
_FakeContactsProvider(this._contacts);
|
||||||
|
|
||||||
|
final List<Contact> _contacts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Contact> get contacts => _contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeConnectionProvider extends ConnectionProvider {
|
||||||
|
_FakeConnectionProvider({required bool isConnected})
|
||||||
|
: _isConnected = isConnected;
|
||||||
|
|
||||||
|
final bool _isConnected;
|
||||||
|
|
||||||
|
int pingCalls = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DeviceInfo get deviceInfo => DeviceInfo(
|
||||||
|
connectionState: _isConnected
|
||||||
|
? ConnectionState.connected
|
||||||
|
: ConnectionState.disconnected,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PingResult> smartPing({
|
||||||
|
required Uint8List contactPublicKey,
|
||||||
|
required bool hasPath,
|
||||||
|
Function()? onRetryWithFlooding,
|
||||||
|
}) async {
|
||||||
|
pingCalls += 1;
|
||||||
|
return const PingResult(
|
||||||
|
success: true,
|
||||||
|
usedFlooding: false,
|
||||||
|
timedOut: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
Future<void> waitUntilLoaded(SensorsProvider provider) async {
|
||||||
|
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
expect(provider.isLoaded, isTrue);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact buildSensorContact() {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x44;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('metric label overrides persist across reloads', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
await provider.setMetricLabel(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
'extra:illuminance_2',
|
||||||
|
'Solar',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.labelOverrideFor(contact.publicKeyHex, 'extra:illuminance_2'),
|
||||||
|
'Solar',
|
||||||
|
);
|
||||||
|
|
||||||
|
final reloadedProvider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(reloadedProvider);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reloadedProvider.labelOverrideFor(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
'extra:illuminance_2',
|
||||||
|
),
|
||||||
|
'Solar',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metric order persists across reloads', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
await provider.moveMetric(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
availableFieldKeys: const ['voltage', 'battery', 'temperature'],
|
||||||
|
oldIndex: 2,
|
||||||
|
newIndex: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.metricOrderFor(contact.publicKeyHex, const [
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
]),
|
||||||
|
const ['temperature', 'voltage', 'battery'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final reloadedProvider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(reloadedProvider);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
reloadedProvider.metricOrderFor(contact.publicKeyHex, const [
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
]),
|
||||||
|
const ['temperature', 'voltage', 'battery'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto refresh minutes persist across reloads', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5);
|
||||||
|
|
||||||
|
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
||||||
|
|
||||||
|
final reloadedProvider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(reloadedProvider);
|
||||||
|
|
||||||
|
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshDueSensors respects per-contact interval', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
final contactsProvider = _FakeContactsProvider(<Contact>[contact]);
|
||||||
|
final connectionProvider = _FakeConnectionProvider(isConnected: true);
|
||||||
|
final start = DateTime(2026, 3, 15, 9, 0);
|
||||||
|
|
||||||
|
final provider = SensorsProvider();
|
||||||
|
await waitUntilLoaded(provider);
|
||||||
|
await provider.addSensor(contact);
|
||||||
|
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 1);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start.add(const Duration(minutes: 4)),
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 1);
|
||||||
|
|
||||||
|
await provider.refreshDueSensors(
|
||||||
|
now: start.add(const Duration(minutes: 5)),
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
expect(connectionProvider.pingCalls, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
224
test/screens/contacts_tab_test.dart
Normal file
224
test/screens/contacts_tab_test.dart
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact buildSensor({required int seed, required String name}) {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = seed;
|
||||||
|
publicKey[1] = seed + 1;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sensor contacts render in their own section', (tester) async {
|
||||||
|
await pumpContactsTab(
|
||||||
|
tester,
|
||||||
|
contacts: [buildSensor(seed: 60, name: 'WX Station')],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Sensors'), findsOneWidget);
|
||||||
|
expect(find.text('WX Station'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsOneWidget);
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
expect(find.text('Quiet'), findsOneWidget);
|
expect(find.text('Quiet'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,9 +115,15 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('1 pkt/min'), findsOneWidget);
|
expect(find.text('1 pkt/min'), findsOneWidget);
|
||||||
expect(find.text('Device total 7'), findsOneWidget);
|
expect(find.text('Device total 7'), findsOneWidget);
|
||||||
expect(find.textContaining('RESP'), findsOneWidget);
|
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
|
||||||
expect(find.text('MULTI-HOP'), findsOneWidget);
|
expect(find.text('MULTI-HOP'), findsOneWidget);
|
||||||
expect(find.textContaining('RSSI -84 dBm'), 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', (
|
testWidgets('clear live view only resets transient screen state', (
|
||||||
@@ -151,7 +157,7 @@ void main() {
|
|||||||
await tester.tap(find.byTooltip('Clear live view'));
|
await tester.tap(find.byTooltip('Clear live view'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsOneWidget);
|
expect(find.text('No packets for this filter'), findsOneWidget);
|
||||||
|
|
||||||
now = now.add(const Duration(seconds: 2));
|
now = now.add(const Duration(seconds: 2));
|
||||||
logs.add(
|
logs.add(
|
||||||
@@ -173,7 +179,7 @@ void main() {
|
|||||||
refresh.value += 1;
|
refresh.value += 1;
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('No live traffic yet'), findsNothing);
|
expect(find.text('No packets for this filter'), findsNothing);
|
||||||
expect(find.textContaining('3 bytes'), findsOneWidget);
|
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
211
test/screens/messages_tab_test.dart
Normal file
211
test/screens/messages_tab_test.dart
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
100
test/screens/sensors_tab_test.dart
Normal file
100
test/screens/sensors_tab_test.dart
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> waitUntilLoaded(SensorsProvider provider) async {
|
||||||
|
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
expect(provider.isLoaded, isTrue);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact buildSensorContact() {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x44;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
batteryPercentage: 84,
|
||||||
|
temperature: 21.5,
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:battery': 1,
|
||||||
|
'__source_channel:temperature': 1,
|
||||||
|
'illuminance_2': 500.0,
|
||||||
|
},
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('customize sheet shows metric value previews and channels', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final contact = buildSensorContact();
|
||||||
|
final sensorsProvider = SensorsProvider();
|
||||||
|
final contactsProvider = ContactsProvider();
|
||||||
|
|
||||||
|
await waitUntilLoaded(sensorsProvider);
|
||||||
|
contactsProvider.addOrUpdateContact(contact);
|
||||||
|
await sensorsProvider.addSensor(contact);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ContactsProvider>.value(
|
||||||
|
value: contactsProvider,
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const SensorsTab(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.more_vert));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(find.text('Customize fields'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_selector_value_extra:illuminance_2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('500 lx'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('ch2'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -360,6 +360,78 @@ void main() {
|
|||||||
expect(accel['z'], closeTo(2.0, 0.001));
|
expect(accel['z'], closeTo(2.0, 0.001));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('extended MeshCore LPP types decode to structured extra data', () {
|
||||||
|
const lppGenericSensor = 100;
|
||||||
|
const lppCurrent = 117;
|
||||||
|
const lppFrequency = 118;
|
||||||
|
const lppAltitude = 121;
|
||||||
|
const lppConcentration = 125;
|
||||||
|
const lppPower = 128;
|
||||||
|
const lppSpeed = 129;
|
||||||
|
const lppDistance = 130;
|
||||||
|
const lppEnergy = 131;
|
||||||
|
const lppDirection = 132;
|
||||||
|
const lppUnixTime = 133;
|
||||||
|
const lppColour = 135;
|
||||||
|
const lppSwitch = 142;
|
||||||
|
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300
|
||||||
|
3, lppCurrent, 0x00, 0x0F, // 0.015 A
|
||||||
|
4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz
|
||||||
|
5, lppAltitude, 0x01, 0xF4, // 500 m
|
||||||
|
6, lppConcentration, 0x01, 0x9F, // 415 ppm
|
||||||
|
7, lppPower, 0x00, 0xFA, // 250 W
|
||||||
|
8, lppSpeed, 0x04, 0xD2, // 12.34 m/s
|
||||||
|
9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m
|
||||||
|
10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh
|
||||||
|
11, lppDirection, 0x01, 0x0E, // 270 deg
|
||||||
|
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
|
||||||
|
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
|
||||||
|
14, lppSwitch, 0x01, // on
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.extraSensorData, isNotNull);
|
||||||
|
expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0));
|
||||||
|
expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001));
|
||||||
|
expect(decoded.extraSensorData!['frequency_4'], equals(1000.0));
|
||||||
|
expect(decoded.extraSensorData!['altitude_5'], equals(500.0));
|
||||||
|
expect(decoded.extraSensorData!['concentration_6'], equals(415.0));
|
||||||
|
expect(decoded.extraSensorData!['power_7'], equals(250.0));
|
||||||
|
expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001));
|
||||||
|
expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001));
|
||||||
|
expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001));
|
||||||
|
expect(decoded.extraSensorData!['direction_11'], equals(270.0));
|
||||||
|
expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456));
|
||||||
|
expect(
|
||||||
|
decoded.extraSensorData!['colour_13'],
|
||||||
|
equals({'r': 255, 'g': 128, 'b': 64}),
|
||||||
|
);
|
||||||
|
expect(decoded.extraSensorData!['switch_14'], equals(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'percentage battery and non-battery voltage channels are preserved separately',
|
||||||
|
() {
|
||||||
|
const lppPercentage = 120;
|
||||||
|
|
||||||
|
final payload = Uint8List.fromList([
|
||||||
|
1, lppPercentage, 66, // battery %
|
||||||
|
2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V
|
||||||
|
2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C
|
||||||
|
]);
|
||||||
|
|
||||||
|
final decoded = CayenneLppParser.parse(payload);
|
||||||
|
|
||||||
|
expect(decoded.batteryPercentage, equals(66.0));
|
||||||
|
expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001));
|
||||||
|
expect(decoded.temperature, closeTo(23.5, 0.1));
|
||||||
|
expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('unknown sensor type is skipped gracefully', () {
|
test('unknown sensor type is skipped gracefully', () {
|
||||||
final buffer = ByteData(5);
|
final buffer = ByteData(5);
|
||||||
buffer.setUint8(0, 0);
|
buffer.setUint8(0, 0);
|
||||||
|
|||||||
@@ -122,6 +122,27 @@ void main() {
|
|||||||
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
|
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', () {
|
test('supports clearing the live view without mutating source logs', () {
|
||||||
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
final now = DateTime(2026, 3, 12, 12, 0, 0);
|
||||||
final clearAt = now.subtract(const Duration(seconds: 8));
|
final clearAt = now.subtract(const Duration(seconds: 8));
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ void main() {
|
|||||||
await prefs.remove('stored_message_reception_details');
|
await prefs.remove('stored_message_reception_details');
|
||||||
|
|
||||||
final restoredDetails = await storage.loadMessageReceptionDetails();
|
final restoredDetails = await storage.loadMessageReceptionDetails();
|
||||||
|
final restoredMessages = await storage.loadMessages();
|
||||||
|
|
||||||
expect(restoredDetails.keys, contains(message.id));
|
expect(restoredDetails.keys, contains(message.id));
|
||||||
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
|
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ void main() {
|
|||||||
senderToReceiptMs: 1200,
|
senderToReceiptMs: 1200,
|
||||||
estimatedTransmitMs: 800,
|
estimatedTransmitMs: 800,
|
||||||
postTransmitDelayMs: 400,
|
postTransmitDelayMs: 400,
|
||||||
|
receivedCopies: 3,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -93,9 +96,20 @@ void main() {
|
|||||||
expect(restored.senderToReceiptMs, 1200);
|
expect(restored.senderToReceiptMs, 1200);
|
||||||
expect(restored.estimatedTransmitMs, 800);
|
expect(restored.estimatedTransmitMs, 800);
|
||||||
expect(restored.postTransmitDelayMs, 400);
|
expect(restored.postTransmitDelayMs, 400);
|
||||||
|
expect(restored.receivedCopies, 3);
|
||||||
expect(
|
expect(
|
||||||
restored.packetLoggedAt,
|
restored.packetLoggedAt,
|
||||||
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
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'}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
25
test/services/notification_service_test.dart
Normal file
25
test/services/notification_service_test.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/notification_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discovery notification preference persists independently', () async {
|
||||||
|
final service = NotificationService();
|
||||||
|
|
||||||
|
await service.setMessageNotificationsEnabled(true);
|
||||||
|
await service.setDiscoveryNotificationsEnabled(false);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
expect(service.messageNotificationsEnabled, isTrue);
|
||||||
|
expect(service.discoveryNotificationsEnabled, isFalse);
|
||||||
|
expect(prefs.getBool('notifications_messages_enabled'), isTrue);
|
||||||
|
expect(prefs.getBool('notifications_discovery_enabled'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,92 @@ void main() {
|
|||||||
expect(resolved.usedOnlineFallback, isTrue);
|
expect(resolved.usedOnlineFallback, isTrue);
|
||||||
expect(resolved.matchCount, 1);
|
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({
|
MeshMapNode _node({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
90
test/widgets/bidirectional_refresh_test.dart
Normal file
90
test/widgets/bidirectional_refresh_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ void main() {
|
|||||||
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
|
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
|
home: Scaffold(
|
||||||
|
body: Center(child: ContactAvatar(contact: contact)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,7 +87,11 @@ void main() {
|
|||||||
testWidgets('renders non-hash label avatar for channels', (tester) async {
|
testWidgets('renders non-hash label avatar for channels', (tester) async {
|
||||||
await pumpAvatar(
|
await pumpAvatar(
|
||||||
tester,
|
tester,
|
||||||
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
|
buildContact(
|
||||||
|
name: 'Command Net',
|
||||||
|
type: ContactType.channel,
|
||||||
|
secondByte: 3,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('CN'), findsOneWidget);
|
expect(find.text('CN'), findsOneWidget);
|
||||||
@@ -103,4 +109,15 @@ void main() {
|
|||||||
expect(find.byType(CircleAvatar), findsOneWidget);
|
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.person), findsNothing);
|
expect(find.byIcon(Icons.person), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders sensor icon avatar for sensor contacts', (tester) async {
|
||||||
|
await pumpAvatar(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'WX Station', type: ContactType.sensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.sensors), findsOneWidget);
|
||||||
|
expect(find.text('WS'), findsNothing);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:meshcore_sar_app/providers/map_provider.dart';
|
|||||||
import 'package:meshcore_sar_app/providers/messages_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/providers/sensors_provider.dart';
|
||||||
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
|
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -40,14 +41,21 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pumpTile(WidgetTester tester, Contact contact) async {
|
Future<void> pumpTile(
|
||||||
|
WidgetTester tester,
|
||||||
|
Contact contact, {
|
||||||
|
SensorsProvider? sensorsProvider,
|
||||||
|
}) async {
|
||||||
|
final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider();
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
ChangeNotifierProvider(create: (_) => ContactsProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
ChangeNotifierProvider(create: (_) => MessagesProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
ChangeNotifierProvider<SensorsProvider>.value(
|
||||||
|
value: resolvedSensorsProvider,
|
||||||
|
),
|
||||||
ChangeNotifierProvider(create: (_) => MapProvider()),
|
ChangeNotifierProvider(create: (_) => MapProvider()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
@@ -82,4 +90,101 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Trace'), findsNothing);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sensor contacts can be added to sensors', (tester) async {
|
||||||
|
await pumpTile(
|
||||||
|
tester,
|
||||||
|
buildContact(name: 'WX Station', type: ContactType.sensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('WX Station'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Add to Sensors'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('sensor preview shows telemetry card', (tester) async {
|
||||||
|
final contact = buildContact(name: 'WX Station', type: ContactType.sensor)
|
||||||
|
.copyWith(
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
batteryPercentage: 84,
|
||||||
|
temperature: 21.5,
|
||||||
|
humidity: 58.0,
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:battery': 1,
|
||||||
|
'__source_channel:temperature': 1,
|
||||||
|
'__source_channel:humidity': 1,
|
||||||
|
'co2': 415.0,
|
||||||
|
'illuminance_2': 500.0,
|
||||||
|
'current_2': 0.015,
|
||||||
|
'power_2': 0.25,
|
||||||
|
'distance_2': 1.234,
|
||||||
|
},
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpTile(tester, contact);
|
||||||
|
|
||||||
|
await tester.tap(find.text('WX Station'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Preview'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Preview'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Battery'), findsOneWidget);
|
||||||
|
expect(find.text('84%'), findsOneWidget);
|
||||||
|
expect(find.text('Temperature'), findsOneWidget);
|
||||||
|
expect(find.text('21.5°C'), findsOneWidget);
|
||||||
|
expect(find.text('CO2'), findsOneWidget);
|
||||||
|
expect(find.text('415 ppm'), findsOneWidget);
|
||||||
|
expect(find.text('Illuminance'), findsOneWidget);
|
||||||
|
expect(find.text('~4.2 W/m2 daylight'), findsOneWidget);
|
||||||
|
expect(find.text('Current'), findsOneWidget);
|
||||||
|
expect(find.text('15 mA'), findsOneWidget);
|
||||||
|
expect(find.text('Power'), findsOneWidget);
|
||||||
|
expect(find.text('Distance'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_channel_battery')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text('ch1'),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
|
||||||
|
final batteryTileSize = tester.getSize(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_battery')),
|
||||||
|
);
|
||||||
|
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
40
test/widgets/sensor_metric_selector_item_test.dart
Normal file
40
test/widgets/sensor_metric_selector_item_test.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders selector previews and channel badges', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorMetricSelectorItem(
|
||||||
|
option: const SensorMetricOption(
|
||||||
|
key: 'extra:illuminance_2',
|
||||||
|
label: 'Illuminance (ch 2)',
|
||||||
|
defaultLabel: 'Illuminance',
|
||||||
|
channel: 2,
|
||||||
|
valuePreview: '500 lx',
|
||||||
|
),
|
||||||
|
visible: true,
|
||||||
|
span: 1,
|
||||||
|
canMoveUp: true,
|
||||||
|
canMoveDown: true,
|
||||||
|
onToggle: (_) {},
|
||||||
|
onRename: () {},
|
||||||
|
onMoveUp: () {},
|
||||||
|
onMoveDown: () {},
|
||||||
|
onSpanChanged: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('500 lx'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('ch2'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
144
test/widgets/sensor_telemetry_card_test.dart
Normal file
144
test/widgets/sensor_telemetry_card_test.dart
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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/sensors_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Contact buildContact() {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x44;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
temperature: 21.5,
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:temperature': 1,
|
||||||
|
'illuminance_2': 500.0,
|
||||||
|
},
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('renders custom labels and channel badges', (tester) async {
|
||||||
|
final contact = buildContact();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {'temperature', 'extra:illuminance_2'},
|
||||||
|
labelOverrides: const {
|
||||||
|
'temperature': 'Ambient',
|
||||||
|
'extra:illuminance_2': 'Light',
|
||||||
|
},
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(
|
||||||
|
const {'temperature', 'extra:illuminance_2'},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Ambient'), findsOneWidget);
|
||||||
|
expect(find.text('Light'), findsOneWidget);
|
||||||
|
expect(find.text('Temperature'), findsNothing);
|
||||||
|
expect(find.text('Illuminance'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_channel_temperature')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders metrics in the provided order', (tester) async {
|
||||||
|
final publicKey = Uint8List(32);
|
||||||
|
publicKey[0] = 0x45;
|
||||||
|
final contact = Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX Station',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
batteryPercentage: 84,
|
||||||
|
temperature: 21.5,
|
||||||
|
extraSensorData: const {
|
||||||
|
'__source_channel:battery': 1,
|
||||||
|
'__source_channel:temperature': 1,
|
||||||
|
'illuminance_2': 500.0,
|
||||||
|
},
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SensorTelemetryCard(
|
||||||
|
contact: contact,
|
||||||
|
state: SensorRefreshState.idle,
|
||||||
|
visibleFields: const {
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'extra:illuminance_2',
|
||||||
|
},
|
||||||
|
fieldOrder: const [
|
||||||
|
'extra:illuminance_2',
|
||||||
|
'temperature',
|
||||||
|
'battery',
|
||||||
|
],
|
||||||
|
fieldSpans: sensorFullWidthFieldSpans(
|
||||||
|
const {
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'extra:illuminance_2',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final illuminanceTop = tester.getTopLeft(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
|
||||||
|
);
|
||||||
|
final temperatureTop = tester.getTopLeft(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_temperature')),
|
||||||
|
);
|
||||||
|
final batteryTop = tester.getTopLeft(
|
||||||
|
find.byKey(const ValueKey('sensor_metric_battery')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(illuminanceTop.dy, lessThan(temperatureTop.dy));
|
||||||
|
expect(temperatureTop.dy, lessThan(batteryTop.dy));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user