Compare commits

...

32 Commits

Author SHA1 Message Date
Janez T
a55cfffa59 Redesign contact import screen 2026-03-15 21:02:17 +01:00
Janez T
2bb5b353bd Fix channel refresh telemetry how 2026-03-15 21:00:02 +01:00
Janez T
991421d097 Fix telemetry merge bug 2026-03-15 20:58:12 +01:00
Janez Troha
1dcd85f178 Merge pull request #26 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 20:14:31 +01:00
Janez T
5d2f61749e Update sensor parsing layout 2026-03-15 20:14:01 +01:00
Janez T
69377d90a4 Update auto-refresh and UI 2026-03-15 11:15:19 +01:00
Janez T
3e559a4f4e Enforce theme colors on switches 2026-03-15 10:56:47 +01:00
Janez T
a124e3d9f2 Improve sensor settings UI 2026-03-15 10:48:48 +01:00
Janez T
3bf4971279 Update sensor preview interactions 2026-03-15 10:43:37 +01:00
Janez T
e355f28ca9 Update sensor customization view 2026-03-15 10:40:16 +01:00
Janez Troha
7a9be3eb76 Merge pull request #25 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 10:34:36 +01:00
Janez T
650f3c66aa Show values in sensor fields 2026-03-15 10:22:53 +01:00
Janez T
df993cd176 Enhance sensor telemetry cards 2026-03-15 10:10:22 +01:00
Janez T
feb3145854 Add sensor contact preview sheet 2026-03-15 09:36:31 +01:00
Janez T
da40acf950 chore: Update Meshcore client pin
ref:
2026-03-15 09:31:18 +01:00
Janez T
66eb32bf44 Fix BLE contact add failure 2026-03-15 09:24:41 +01:00
Janez T
c2e0fa952e Fix contact add failure 2026-03-15 09:19:59 +01:00
Janez T
4557fb4b9e feat: Add Sensor telemetry alerts
ref:
2026-03-15 09:13:32 +01:00
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
65 changed files with 11525 additions and 3719 deletions

13
CHANGELOG.md Normal file
View File

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

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 115; CURRENT_PROJECT_VERSION = 117;
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 = 115; CURRENT_PROJECT_VERSION = 117;
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 = 115; CURRENT_PROJECT_VERSION = 117;
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 = 115; CURRENT_PROJECT_VERSION = 117;
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 = 115; CURRENT_PROJECT_VERSION = 117;
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 = 115; CURRENT_PROJECT_VERSION = 117;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

@@ -5,24 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001473"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.569758"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="95.271262"> <testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="91.795184"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Gem.activate_and_load_bin_path&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Invalid username and password combination. Used &apos;hey@dz0n.dev&apos; as the username." />
</testcase> </testcase>

View File

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

View File

@@ -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,

View File

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

View File

@@ -1,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})';
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_provider.dart'; import 'contacts_provider.dart';
@@ -17,6 +19,7 @@ import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart'; import '../services/packet_capture_storage_service.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 '../services/notification_service.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/ble_packet_log.dart'; import '../models/ble_packet_log.dart';
@@ -56,9 +59,65 @@ class _DirectMessageRouteSession {
} }
} }
class _ParsedRawAdvert {
final Uint8List publicKey;
final String? advName;
final int typeValue;
final int flags;
final int lastAdvert;
final int? advLat;
final int? advLon;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
const _ParsedRawAdvert({
required this.publicKey,
required this.advName,
required this.typeValue,
required this.flags,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.signedEncodedPathLen,
required this.paddedPathBytes,
});
}
class _ParsedRepeaterStatus {
final int batteryMv;
final int queueLen;
final int lastRssi;
final int lastSnrRaw;
final int uptimeSecs;
const _ParsedRepeaterStatus({
required this.batteryMv,
required this.queueLen,
required this.lastRssi,
required this.lastSnrRaw,
required this.uptimeSecs,
});
}
class _PendingRepeaterOwnerRequest {
final Uint8List publicKey;
const _PendingRepeaterOwnerRequest({required this.publicKey});
}
/// Main App Provider - coordinates all other providers /// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier { class AppProvider with ChangeNotifier {
static const int _maxDirectPayloadHops = 3; static const int _maxDirectPayloadHops = 3;
static const int _rawPayloadTypeAdvert = 0x04;
static const int _routeTransportFlood = 0x00;
static const int _routeTransportDirect = 0x03;
static const int _anonReqTypeOwner = 0x02;
static const double _lowBatteryThresholdPercent = 30.0;
static const double _lowBatteryResetThresholdPercent = 35.0;
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
static const Duration _repeaterOwnerInfoRequestCooldown = Duration(
minutes: 10,
);
@visibleForTesting @visibleForTesting
static bool isDeletedChannelInfo( static bool isDeletedChannelInfo(
int channelIdx, int channelIdx,
@@ -88,6 +147,7 @@ class AppProvider with ChangeNotifier {
LocationTrackingService(); LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService = final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService(); PacketCaptureStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
@@ -117,8 +177,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
bool _autoRouteRotationEnabled = bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled; MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled; bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
@@ -148,13 +206,19 @@ class AppProvider with ChangeNotifier {
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {}; final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>> final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {}; _pendingMediaSwarmResponses = {};
final Map<String, DateTime> _recentRepeaterStatusRequests = {};
final Map<String, DateTime> _recentRepeaterOwnerInfoRequests = {};
final Map<int, _PendingRepeaterOwnerRequest> _pendingRepeaterOwnerRequests =
{};
bool _fastLocationScreenActive = false; bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
Timer? _lowBatteryCheckTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false; bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false; bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false; bool _isReconnectSyncInProgress = false;
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
@@ -181,10 +245,10 @@ class AppProvider with ChangeNotifier {
_loadVoiceEchoCancellationEnabled(); _loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled(); _loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize()); unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence(); _startPacketCapturePersistence();
_startLowBatteryWatcher();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load _syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true; _isInitialized = true;
} }
@@ -197,6 +261,58 @@ class AppProvider with ChangeNotifier {
unawaited(_flushPacketCaptureLogs()); unawaited(_flushPacketCaptureLogs());
} }
void _startLowBatteryWatcher() {
_lowBatteryCheckTimer?.cancel();
_lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) {
unawaited(_checkLowBatteryAlerts());
});
unawaited(_checkLowBatteryAlerts());
}
Future<void> _checkLowBatteryAlerts() async {
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
if (deviceBattery != null &&
deviceBattery > _lowBatteryResetThresholdPercent) {
_lowBatteryNotifiedNodeIds.remove('device');
}
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
await _notifyLowBatteryIfNeeded(
nodeId: 'device',
nodeName:
connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: (connectionProvider.deviceInfo.displayName ?? 'Connected device'),
batteryPercent: deviceBattery,
isCurrentDevice: true,
);
}
}
Future<void> _notifyLowBatteryIfNeeded({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (batteryPercent >= _lowBatteryThresholdPercent) {
return;
}
if (_lowBatteryNotifiedNodeIds.contains(nodeId)) {
return;
}
final shown = await _notificationService.showLowBatteryNotification(
nodeId: nodeId,
nodeName: nodeName,
batteryPercent: batteryPercent,
isCurrentDevice: isCurrentDevice,
);
if (shown) {
_lowBatteryNotifiedNodeIds.add(nodeId);
}
}
String _packetLogSignature(BlePacketLog log) { String _packetLogSignature(BlePacketLog log) {
final prefix = log.rawData.length <= 12 final prefix = log.rawData.length <= 12
? log.rawData ? log.rawData
@@ -249,17 +365,15 @@ class AppProvider with ChangeNotifier {
/// Sync drawings from messages on app startup (before BLE connection) /// Sync drawings from messages on app startup (before BLE connection)
Future<void> _syncDrawingsOnStartup() async { Future<void> _syncDrawingsOnStartup() async {
// Wait for MessagesProvider to finish initializing // Wait for both MessagesProvider and DrawingProvider to finish initializing
// DrawingProvider loads around the same time
int attempts = 0; int attempts = 0;
while (!messagesProvider.isInitialized && attempts < 20) { while ((!messagesProvider.isInitialized ||
!drawingProvider.isInitialized) &&
attempts < 40) {
await Future.delayed(const Duration(milliseconds: 50)); await Future.delayed(const Duration(milliseconds: 50));
attempts++; attempts++;
} }
// Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100));
_restoreSessionMetadataFromMessages(); _restoreSessionMetadataFromMessages();
debugPrint( debugPrint(
@@ -652,30 +766,6 @@ class AppProvider with ChangeNotifier {
} }
} }
/// Load auto-add discovered contacts setting from shared preferences.
Future<void> _loadAutoAddDiscoveredContacts() async {
try {
final prefs = await SharedPreferences.getInstance();
_autoAddDiscoveredContacts =
prefs.getBool('auto_add_discovered_contacts') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading auto-add discovered contacts setting: $e');
}
}
/// Toggle auto-add discovered contacts on/off.
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
try {
_autoAddDiscoveredContacts = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_add_discovered_contacts', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto-add discovered contacts setting: $e');
}
}
Future<void> _loadMessagingRouteSettings() async { Future<void> _loadMessagingRouteSettings() async {
try { try {
_autoRouteRotationEnabled = _autoRouteRotationEnabled =
@@ -805,16 +895,49 @@ class AppProvider with ChangeNotifier {
); );
}; };
// When a contact is received from BLE // When a contact is received from BLE
connectionProvider.onContactReceived = (contact) { connectionProvider.onContactReceivedDetailed = (contact, source) {
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final existingContact = contactsProvider.findContactByKey(
contact.publicKey,
);
final isAdvertSource =
source == ContactReceiveSource.advert ||
source == ContactReceiveSource.preview;
if (isAdvertSource) {
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertContact(
contact,
devicePublicKey: devicePublicKey,
);
if (isNewPendingAdvert) {
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: contact.publicKey
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(),
contactName: contact.advName.trim().isEmpty
? null
: contact.advName,
),
);
}
if (contact.type == ContactType.repeater &&
contact.advName.trim().isEmpty) {
unawaited(_maybeRequestRepeaterOwnerInfo(contact.publicKey));
}
if (existingContact == null) {
return;
}
}
// Pass device public key to filter out our own contact // Pass device public key to filter out our own contact
contactsProvider.addOrUpdateContact( contactsProvider.addOrUpdateContact(
contact, contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: devicePublicKey,
); );
unawaited(_pathHistoryService.recordLearnedPath(contact)); unawaited(_pathHistoryService.recordLearnedPath(contact));
// Broadcast to SSE clients if server is running
connectionProvider.broadcastContactToSseClients(contact);
}; };
// When all contacts are received // When all contacts are received
@@ -828,11 +951,6 @@ class AppProvider with ChangeNotifier {
unawaited(_pathHistoryService.recordLearnedPath(contact)); unawaited(_pathHistoryService.recordLearnedPath(contact));
} }
debugPrint('Received ${contacts.length} contacts'); debugPrint('Received ${contacts.length} contacts');
// Broadcast all contacts to SSE clients if server is running
for (final contact in contacts) {
connectionProvider.broadcastContactToSseClients(contact);
}
}; };
// Setup callback for ConnectionProvider to query channel info // Setup callback for ConnectionProvider to query channel info
@@ -975,9 +1093,10 @@ class AppProvider with ChangeNotifier {
if (AppProvider.shouldIgnoreSelfReplay( if (AppProvider.shouldIgnoreSelfReplay(
message: message, message: message,
ownPublicKey: connectionProvider.deviceInfo.publicKey, ownPublicKey: connectionProvider.deviceInfo.publicKey,
ownName: ownName: _preferredSelfDisplayName(
connectionProvider.deviceInfo.deviceName ?? deviceName: connectionProvider.deviceInfo.deviceName,
connectionProvider.deviceInfo.selfName, selfName: connectionProvider.deviceInfo.selfName,
),
)) { )) {
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}'); debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
return; return;
@@ -1057,9 +1176,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
// Broadcast drawing message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(updatedMessage);
} else { } else {
debugPrint('⚠️ [AppProvider] Failed to parse drawing message'); debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
} }
@@ -1100,7 +1216,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
} }
@@ -1134,7 +1249,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return; return;
} }
@@ -1159,9 +1273,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot, contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot,
); );
// Broadcast message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
}; };
// Keep a compact receive-time snapshot because packet logs roll over. // Keep a compact receive-time snapshot because packet logs roll over.
@@ -1180,6 +1291,9 @@ class AppProvider with ChangeNotifier {
// Used by newer firmware versions for telemetry and other binary data // Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility // BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) { connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return;
}
debugPrint( debugPrint(
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry', '📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry',
); );
@@ -1193,6 +1307,46 @@ class AppProvider with ChangeNotifier {
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet. // Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
// Magic 0x49 'I' = image packet. // Magic 0x49 'I' = image packet.
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
final parsedAdvert = _tryParseRawAdvert(payload);
if (parsedAdvert != null) {
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertMetadata(
publicKey: parsedAdvert.publicKey,
typeValue: parsedAdvert.typeValue,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
flags: parsedAdvert.flags,
advName: parsedAdvert.advName,
lastAdvert: parsedAdvert.lastAdvert,
advLat: parsedAdvert.advLat,
advLon: parsedAdvert.advLon,
signedEncodedPathLen: parsedAdvert.signedEncodedPathLen,
paddedPathBytes: parsedAdvert.paddedPathBytes,
rxRssiDbm: rssiDbm,
rxSnrRaw: snrRaw,
);
if (isNewPendingAdvert) {
final contactKey = parsedAdvert.publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: contactKey,
contactName: parsedAdvert.advName,
),
);
}
if (parsedAdvert.typeValue == ContactType.repeater.value) {
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
}
if (contactsProvider.shouldEnrichPendingAdvert(
parsedAdvert.publicKey,
)) {
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
}
return;
}
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
if (fastGpsPacket != null) { if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
@@ -1398,6 +1552,31 @@ class AppProvider with ChangeNotifier {
_handleIncomingVoicePacket(pkt, justComplete: justComplete); _handleIncomingVoicePacket(pkt, justComplete: justComplete);
}; };
connectionProvider.onControlDataReceived =
(payload, snrRaw, rssiDbm, pathLen) {
_handleControlDataDiscovery(
payload: payload,
snrRaw: snrRaw,
rssiDbm: rssiDbm,
pathLen: pathLen,
);
};
connectionProvider.onStatusResponse = (publicKeyPrefix, statusData) {
final parsed = _tryParseRepeaterStatus(statusData);
if (parsed == null) {
return;
}
contactsProvider.updatePendingAdvertStatusByPrefix(
publicKeyPrefix,
batteryMv: parsed.batteryMv,
queueLen: parsed.queueLen,
lastRssi: parsed.lastRssi,
lastSnrRaw: parsed.lastSnrRaw,
uptimeSecs: parsed.uptimeSecs,
);
};
// When a contact's routing path is updated in the mesh network // When a contact's routing path is updated in the mesh network
connectionProvider.onPathUpdated = (publicKey) { connectionProvider.onPathUpdated = (publicKey) {
debugPrint( debugPrint(
@@ -1434,22 +1613,30 @@ class AppProvider with ChangeNotifier {
} }
}); });
} else { } else {
if (_autoAddDiscoveredContacts) { final isNewPendingAdvert = contactsProvider.addPendingAdvert(
debugPrint(' Unknown contact - auto-add enabled, fetching details'); publicKey,
Future.delayed(const Duration(milliseconds: 100), () { devicePublicKey: connectionProvider.deviceInfo.publicKey,
if (connectionProvider.deviceInfo.isConnected) { );
connectionProvider.getContact(publicKey); debugPrint(
} ' Unknown contact - added to pending adverts list and waiting for manual resolution',
}); );
} else { if (isNewPendingAdvert) {
contactsProvider.addPendingAdvert( final keyHex = publicKey
publicKey, .take(6)
devicePublicKey: connectionProvider.deviceInfo.publicKey, .map((b) => b.toRadixString(16).padLeft(2, '0'))
); .join();
debugPrint( unawaited(
' Unknown contact - added to pending adverts list and waiting for details', _notificationService.showContactDiscoveredNotification(
contactKey: keyHex,
contactName: contactsProvider
.pendingAdvertByKey(publicKey)
?.advName,
),
); );
} }
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
unawaited(connectionProvider.previewContact(publicKey));
}
} }
}; };
@@ -1861,6 +2048,312 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _requestRepeaterStatus(Uint8List publicKey) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final keyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final now = DateTime.now();
final lastRequestAt = _recentRepeaterStatusRequests[keyHex];
if (lastRequestAt != null &&
now.difference(lastRequestAt) < const Duration(minutes: 2)) {
return;
}
_recentRepeaterStatusRequests[keyHex] = now;
try {
await connectionProvider.requestStatus(publicKey);
} catch (_) {
// Ignore unsupported or unreachable repeaters.
}
}
Future<void> _maybeRequestRepeaterOwnerInfo(Uint8List publicKey) async {
final pendingAdvert = contactsProvider.pendingAdvertByKey(publicKey);
if (pendingAdvert == null ||
pendingAdvert.typeValue != ContactType.repeater.value ||
pendingAdvert.advName?.trim().isNotEmpty == true ||
!connectionProvider.deviceInfo.isConnected) {
return;
}
final keyHex = pendingAdvert.publicKeyHex;
final now = DateTime.now();
final lastRequestAt = _recentRepeaterOwnerInfoRequests[keyHex];
if (lastRequestAt != null &&
now.difference(lastRequestAt) < _repeaterOwnerInfoRequestCooldown) {
return;
}
Contact? existingContact;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == keyHex) {
existingContact = contact;
break;
}
}
var temporaryContactAdded = false;
final requestContact =
existingContact ?? _temporaryRepeaterContactForOwnerInfo(pendingAdvert);
if (requestContact == null || !requestContact.routeHasPath) {
return;
}
_recentRepeaterOwnerInfoRequests[keyHex] = now;
if (existingContact == null) {
connectionProvider.clearError();
await connectionProvider.addOrUpdateContact(requestContact);
if (connectionProvider.error != null) {
return;
}
connectionProvider.clearError();
temporaryContactAdded = true;
}
try {
final ticket = await connectionProvider.sendAnonRequest(
contactPublicKey: publicKey,
requestData: _buildRepeaterOwnerRequest(requestContact),
);
if (ticket == null) {
return;
}
_pendingRepeaterOwnerRequests[ticket.tag] = _PendingRepeaterOwnerRequest(
publicKey: Uint8List.fromList(publicKey),
);
Future.delayed(
Duration(milliseconds: ticket.suggestedTimeoutMs + 1500),
() => _pendingRepeaterOwnerRequests.remove(ticket.tag),
);
} catch (_) {
// Ignore unsupported or unreachable repeaters.
} finally {
if (temporaryContactAdded) {
Future.delayed(const Duration(milliseconds: 250), () async {
await connectionProvider.removeContact(publicKey);
connectionProvider.clearError();
});
}
}
}
Contact? _temporaryRepeaterContactForOwnerInfo(PendingAdvert advert) {
final signedEncodedPathLen = advert.signedEncodedPathLen;
if (signedEncodedPathLen == null) {
return null;
}
final advName = advert.advName?.trim();
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.repeater,
flags: advert.flags ?? 0,
outPathLen: signedEncodedPathLen,
outPath: advert.paddedPathBytes == null
? Uint8List(ContactRouteCodec.maxPathBytes)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName?.isNotEmpty == true ? advName! : advert.shortDisplayKey,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
Uint8List _buildRepeaterOwnerRequest(Contact contact) {
final replyPathDescriptor = contact.routeEncodedPathLen;
final replyPathBytes = contact.routeHopCount > 0
? Uint8List.fromList(
LogRxRouteDecoder.reverseHopBytes(
contact.routePathBytes,
hashSize: contact.routeHashSize,
),
)
: Uint8List(0);
return Uint8List.fromList([
_anonReqTypeOwner,
replyPathDescriptor,
...replyPathBytes,
]);
}
bool _handlePendingRepeaterOwnerResponse(int tag, Uint8List responseData) {
final request = _pendingRepeaterOwnerRequests.remove(tag);
if (request == null) {
return false;
}
final ownerName = _tryParseRepeaterOwnerName(responseData);
if (ownerName == null || ownerName.isEmpty) {
return true;
}
final existing = contactsProvider.pendingAdvertByKey(request.publicKey);
contactsProvider.addOrUpdatePendingAdvertMetadata(
publicKey: request.publicKey,
typeValue: existing?.typeValue ?? ContactType.repeater.value,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
advName: ownerName,
);
return true;
}
String? _tryParseRepeaterOwnerName(Uint8List responseData) {
if (responseData.length <= 4) {
return null;
}
try {
final payload = utf8
.decode(responseData.sublist(4), allowMalformed: true)
.trim();
if (payload.isEmpty) {
return null;
}
final firstLine = payload.split(RegExp(r'[\r\n]+')).first.trim();
return firstLine.isEmpty ? null : firstLine;
} catch (_) {
return null;
}
}
_ParsedRepeaterStatus? _tryParseRepeaterStatus(Uint8List statusData) {
if (statusData.length < 52) {
return null;
}
try {
final data = ByteData.sublistView(statusData);
var offset = 0;
final batteryMv = data.getUint16(offset, Endian.little);
offset += 2;
final queueLen = data.getUint16(offset, Endian.little);
offset += 2;
offset += 2; // noiseFloor
final lastRssi = data.getInt16(offset, Endian.little);
offset += 2;
offset += 4; // packetsRecv
offset += 4; // packetsSent
offset += 4; // txAirSecs
final uptimeSecs = data.getUint32(offset, Endian.little);
offset += 4;
offset += 4; // floodTx
offset += 4; // directTx
offset += 4; // floodRx
offset += 4; // directRx
offset += 2; // errEvents
final lastSnrRaw = data.getInt16(offset, Endian.little);
return _ParsedRepeaterStatus(
batteryMv: batteryMv,
queueLen: queueLen,
lastRssi: lastRssi,
lastSnrRaw: lastSnrRaw,
uptimeSecs: uptimeSecs,
);
} catch (_) {
return null;
}
}
_ParsedRawAdvert? _tryParseRawAdvert(Uint8List rawPayload) {
if (rawPayload.length < 103) {
return null;
}
try {
final reader = BufferReader(rawPayload);
final header = reader.readByte();
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
if (payloadType != _rawPayloadTypeAdvert) {
return null;
}
if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) {
if (reader.remainingBytesCount < 4) {
return null;
}
reader.skip(4);
}
if (reader.remainingBytesCount < 1) {
return null;
}
final pathByteLen = reader.readByte();
if (reader.remainingBytesCount < pathByteLen + 101) {
return null;
}
final pathBytes = reader.readBytes(pathByteLen);
final publicKey = reader.readBytes(32);
final timestamp = reader.readInt32LE();
reader.skip(64);
final flags = reader.readByte();
final typeValue = flags & 0x0F;
final hasLocation = (flags & 0x10) != 0;
final hasName = (flags & 0x80) != 0;
int? advLat;
int? advLon;
if (hasLocation) {
if (reader.remainingBytesCount < 8) {
return null;
}
advLat = reader.readInt32LE();
advLon = reader.readInt32LE();
}
String? advName;
if (hasName && reader.remainingBytesCount > 0) {
final decodedName = utf8
.decode(reader.readRemainingBytes(), allowMalformed: true)
.trim();
if (decodedName.isNotEmpty) {
advName = decodedName;
}
}
int? signedEncodedPathLen;
Uint8List? paddedPathBytes;
if (pathBytes.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(pathBytes);
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final padded = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, reversedPathBytes.length, reversedPathBytes);
final encodedPathLen =
((hashSize - 1) << 6) |
((reversedPathBytes.length ~/ hashSize) & 0x3F);
signedEncodedPathLen = ContactRouteCodec.toSignedDescriptor(
encodedPathLen,
);
paddedPathBytes = padded;
}
return _ParsedRawAdvert(
publicKey: publicKey,
advName: advName,
typeValue: typeValue,
flags: flags,
lastAdvert: timestamp,
advLat: advLat,
advLon: advLon,
signedEncodedPathLen: signedEncodedPathLen,
paddedPathBytes: paddedPathBytes,
);
} catch (_) {
return null;
}
}
int _inferReceivedPathHashSize( int _inferReceivedPathHashSize(
List<int> pathBytes, { List<int> pathBytes, {
required int preferredHashSize, required int preferredHashSize,
@@ -1897,12 +2390,9 @@ class AppProvider with ChangeNotifier {
// Get battery and storage information // Get battery and storage information
await connectionProvider.getBatteryAndStorage(); await connectionProvider.getBatteryAndStorage();
// Load contacts // Load contacts (waits for device to finish sending all contacts)
await connectionProvider.getContacts(); await connectionProvider.getContacts();
// Small delay to ensure contacts are fully loaded
await Future.delayed(const Duration(milliseconds: 500));
// Sync all channels so slot assignment and channel state mirror the device. // Sync all channels so slot assignment and channel state mirror the device.
final channelsToSync = connectionProvider.deviceInfo.maxChannels; final channelsToSync = connectionProvider.deviceInfo.maxChannels;
debugPrint( debugPrint(
@@ -3079,9 +3569,72 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear(); _imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear(); _voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear(); _imageSessionSenderKey6.clear();
_lowBatteryNotifiedNodeIds.clear();
notifyListeners(); notifyListeners();
} }
void _handleControlDataDiscovery({
required Uint8List payload,
required int snrRaw,
required int rssiDbm,
required int pathLen,
}) {
const int controlTypeMask = 0xF0;
const int controlTypeNodeDiscoverResp = 0x90;
const int minFullDiscoverResponseLength = 6 + 32;
if (payload.length < minFullDiscoverResponseLength) {
return;
}
final controlType = payload[0] & controlTypeMask;
if (controlType != controlTypeNodeDiscoverResp) {
return;
}
final nodeType = payload[0] & 0x0F;
final publicKey = Uint8List.fromList(payload.sublist(6, 38));
final isNewPendingAdvert = contactsProvider
.addOrUpdatePendingAdvertMetadata(
publicKey: publicKey,
typeValue: nodeType,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
rxRssiDbm: rssiDbm,
rxSnrRaw: snrRaw,
signedEncodedPathLen: pathLen == 0 ? 0 : null,
paddedPathBytes: pathLen == 0
? Uint8List(ContactRouteCodec.maxPathBytes)
: null,
);
debugPrint(
'🛰️ [AppProvider] Control discovery response: type=$nodeType '
'pathLen=$pathLen snr=$snrRaw rssi=$rssiDbm new=$isNewPendingAdvert',
);
if (isNewPendingAdvert) {
final keyHex = publicKey
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
unawaited(
_notificationService.showContactDiscoveredNotification(
contactKey: keyHex,
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
),
);
}
if (nodeType == ContactType.repeater.value) {
unawaited(_requestRepeaterStatus(publicKey));
unawaited(_maybeRequestRepeaterOwnerInfo(publicKey));
}
if (contactsProvider.shouldEnrichPendingAdvert(publicKey)) {
unawaited(connectionProvider.previewContact(publicKey));
}
}
/// Get app statistics /// Get app statistics
Map<String, dynamic> get statistics { Map<String, dynamic> get statistics {
return { return {
@@ -3099,6 +3652,7 @@ class AppProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
_packetCaptureFlushTimer?.cancel(); _packetCaptureFlushTimer?.cancel();
_lowBatteryCheckTimer?.cancel();
unawaited(_flushPacketCaptureLogs()); unawaited(_flushPacketCaptureLogs());
// Remove connection state listener // Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange); connectionProvider.removeListener(_handleConnectionStateChange);

View File

@@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
@@ -7,9 +9,7 @@ import 'package:crypto/crypto.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/device_info.dart'; import '../models/device_info.dart';
import '../models/room_login_state.dart'; import '../models/room_login_state.dart';
import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact; import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart'; import 'helpers/room_login_manager.dart';
import 'helpers/message_delivery_tracker.dart'; import 'helpers/message_delivery_tracker.dart';
@@ -55,10 +55,22 @@ class ScannedDevice {
ScannedDevice({required this.device, required this.rssi}); ScannedDevice({required this.device, required this.rssi});
} }
enum ContactReceiveSource { sync, requestedSingle, preview, advert }
class _PendingContactRequest {
final ContactReceiveSource source;
final DateTime requestedAt;
const _PendingContactRequest({
required this.source,
required this.requestedAt,
});
}
/// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi) /// Connection Provider - manages MeshCore device connection (BLE or TCP/WiFi)
class ConnectionProvider with ChangeNotifier { class ConnectionProvider with ChangeNotifier {
static const int _controlTypeNodeDiscoverReq = 0x80;
final MeshCoreBleService _bleService = MeshCoreBleService(); final MeshCoreBleService _bleService = MeshCoreBleService();
final SseServerService _sseServer = SseServerService();
MeshCoreTcpService? _tcpService; MeshCoreTcpService? _tcpService;
/// Expose BLE service for background location tracking /// Expose BLE service for background location tracking
@@ -74,10 +86,6 @@ class ConnectionProvider with ChangeNotifier {
ConnectionMode _connectionMode = ConnectionMode.ble; ConnectionMode _connectionMode = ConnectionMode.ble;
ConnectionMode get connectionMode => _connectionMode; ConnectionMode get connectionMode => _connectionMode;
/// SSE server configuration
SseServerConfig _sseServerConfig = const SseServerConfig();
SseServerConfig get sseServerConfig => _sseServerConfig;
/// TCP host last connected to (for display / reconnection info) /// TCP host last connected to (for display / reconnection info)
String? _tcpHost; String? _tcpHost;
String? get tcpHost => _tcpHost; String? get tcpHost => _tcpHost;
@@ -130,6 +138,9 @@ class ConnectionProvider with ChangeNotifier {
// Completer to wait for response before sending next sync request // Completer to wait for response before sending next sync request
Completer<bool>? _syncResponseCompleter; Completer<bool>? _syncResponseCompleter;
// Completer to wait for contacts sync to finish
Completer<void>? _contactsSyncCompleter;
// Lightweight guards for other commands that can be double-tapped // Lightweight guards for other commands that can be double-tapped
bool _isLoginInProgress = false; bool _isLoginInProgress = false;
DateTime? _lastLoginRequestedAt; DateTime? _lastLoginRequestedAt;
@@ -142,6 +153,13 @@ class ConnectionProvider with ChangeNotifier {
bool _isAdvertInProgress = false; bool _isAdvertInProgress = false;
DateTime? _lastAdvertRequestedAt; DateTime? _lastAdvertRequestedAt;
static const Duration _minAdvertInterval = Duration(milliseconds: 500); static const Duration _minAdvertInterval = Duration(milliseconds: 500);
bool _isContactsSyncInProgress = false;
final Map<String, _PendingContactRequest> _pendingSingleContactRequests = {};
final Map<String, DateTime> _previewContactMisses = {};
static const Duration _singleContactRequestWindow = Duration(seconds: 10);
static const Duration _previewContactMissTtl = Duration(minutes: 10);
bool _suppressNextPreviewNotFoundError = false;
bool? _supportsAutoaddConfig;
// Helper instances // Helper instances
final RoomLoginManager _roomLoginManager = RoomLoginManager(); final RoomLoginManager _roomLoginManager = RoomLoginManager();
@@ -159,6 +177,7 @@ class ConnectionProvider with ChangeNotifier {
// Callbacks for other providers // Callbacks for other providers
Function(Contact)? onContactReceived; Function(Contact)? onContactReceived;
Function(Contact, ContactReceiveSource)? onContactReceivedDetailed;
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived; Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived; Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
@@ -186,6 +205,8 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback; bool Function()? canStartAutomaticMessageSyncCallback;
@@ -238,6 +259,10 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onError = (error, {int? errorCode}) { service.onError = (error, {int? errorCode}) {
if (errorCode == 2 && _suppressNextPreviewNotFoundError) {
_suppressNextPreviewNotFoundError = false;
return;
}
debugPrint('⚠️ [Provider] Error received: $error'); debugPrint('⚠️ [Provider] Error received: $error');
_error = error; _error = error;
if (_deviceInfo.connectionState != ConnectionState.connected) { if (_deviceInfo.connectionState != ConnectionState.connected) {
@@ -249,9 +274,21 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onContactNotFound = (contactPublicKey) async { service.onContactNotFound = (contactPublicKey) async {
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
if (contactPublicKey == null) return; if (contactPublicKey == null) return;
final keyHex = _publicKeyToHex(contactPublicKey);
final request = _pendingSingleContactRequests.remove(keyHex);
if (request?.source == ContactReceiveSource.preview) {
_previewContactMisses[keyHex] = DateTime.now();
_suppressNextPreviewNotFoundError = true;
debugPrint(
'🔧 [Provider] Preview contact not found, suppressing retries for $keyHex',
);
return;
}
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
final operationId = contactPublicKey final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -261,7 +298,6 @@ class ConnectionProvider with ChangeNotifier {
try { try {
await _activeService.addOrUpdateContact(pendingOp.contact!); await _activeService.addOrUpdateContact(pendingOp.contact!);
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) { if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingDirectMessage( _messageDeliveryTracker.trackPendingDirectMessage(
@@ -286,11 +322,18 @@ class ConnectionProvider with ChangeNotifier {
service.onContactReceived = (contact) { service.onContactReceived = (contact) {
debugPrint('📥 [Provider] Contact received: "${contact.advName}"'); debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
final source = _classifyContactReceiveSource(contact.publicKey);
onContactReceivedDetailed?.call(contact, source);
onContactReceived?.call(contact); onContactReceived?.call(contact);
}; };
service.onContactsComplete = (contacts) { service.onContactsComplete = (contacts) {
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}'); debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
_isContactsSyncInProgress = false;
if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete();
}
onContactsComplete?.call(contacts); onContactsComplete?.call(contacts);
}; };
@@ -402,6 +445,8 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) => service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm); onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
service.onDeviceInfoReceived = (deviceInfo) { service.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] DeviceInfo received'); debugPrint('📥 [Provider] DeviceInfo received');
@@ -419,11 +464,6 @@ class ConnectionProvider with ChangeNotifier {
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
); );
notifyListeners(); notifyListeners();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
}; };
service.onSelfInfoReceived = (selfInfo) { service.onSelfInfoReceived = (selfInfo) {
@@ -443,11 +483,6 @@ class ConnectionProvider with ChangeNotifier {
selfName: selfInfo['selfName'] as String?, selfName: selfInfo['selfName'] as String?,
); );
notifyListeners(); notifyListeners();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
}; };
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) { service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
@@ -475,6 +510,17 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
}; };
service.onAutoaddConfigReceived = (config) {
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: config['autoAddUsers'] as bool?,
autoAddRepeaters: config['autoAddRepeaters'] as bool?,
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
);
notifyListeners();
};
service.onTxActivity = () { service.onTxActivity = () {
_txActivity = true; _txActivity = true;
notifyListeners(); notifyListeners();
@@ -583,6 +629,9 @@ class ConnectionProvider with ChangeNotifier {
await stopScan(); await stopScan();
} }
// Ensure we route commands to BLE, not a stale TCP service.
_connectionMode = ConnectionMode.ble;
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(), deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty deviceName: device.platformName.isNotEmpty
@@ -591,6 +640,8 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting, connectionState: ConnectionState.connecting,
); );
_error = null; _error = null;
_supportsAutoaddConfig = null;
_resetSyncState();
debugPrint('✅ [Provider] Device info updated to connecting state'); debugPrint('✅ [Provider] Device info updated to connecting state');
notifyListeners(); notifyListeners();
@@ -620,6 +671,7 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting, connectionState: ConnectionState.connecting,
); );
_error = null; _error = null;
_supportsAutoaddConfig = null;
notifyListeners(); notifyListeners();
// Create fresh TCP service and wire its callbacks // Create fresh TCP service and wire its callbacks
@@ -648,6 +700,8 @@ class ConnectionProvider with ChangeNotifier {
} }
_tcpHost = null; _tcpHost = null;
_connectionMode = ConnectionMode.ble; _connectionMode = ConnectionMode.ble;
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates(); _roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll(); _pingTracker.clearAll();
@@ -670,6 +724,8 @@ class ConnectionProvider with ChangeNotifier {
await _bleService.disconnect(); await _bleService.disconnect();
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates(); _roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll(); _pingTracker.clearAll();
@@ -678,6 +734,24 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Reset message sync state so the next connect/reconnect can sync cleanly.
void _resetSyncState() {
if (_syncResponseCompleter != null &&
!_syncResponseCompleter!.isCompleted) {
_syncResponseCompleter!.complete(false);
}
_syncResponseCompleter = null;
if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete();
}
_contactsSyncCompleter = null;
_isSyncingMessages = false;
_syncRequestedWhileBusy = false;
_noMoreMessages = false;
_pendingAutomaticMessageSync = false;
}
/// Cancel ongoing reconnection attempts /// Cancel ongoing reconnection attempts
/// This is useful when the user wants to manually disconnect during reconnection /// This is useful when the user wants to manually disconnect during reconnection
void cancelReconnection() { void cancelReconnection() {
@@ -719,7 +793,10 @@ class ConnectionProvider with ChangeNotifier {
return _messageDeliveryTracker.getDiagnostics(); return _messageDeliveryTracker.getDiagnostics();
} }
/// Get contacts from device /// Get contacts from device.
///
/// Waits for the device to finish sending all contacts (up to 5 s timeout)
/// so callers don't need an arbitrary delay.
Future<void> getContacts() async { Future<void> getContacts() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -728,10 +805,24 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_isContactsSyncInProgress = true;
_contactsSyncCompleter = Completer<void>();
await _activeService.getContacts(); await _activeService.getContacts();
await _contactsSyncCompleter!.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
debugPrint(
'⚠️ [Provider] Contacts sync timeout - proceeding without full list',
);
},
);
} catch (e) { } catch (e) {
_isContactsSyncInProgress = false;
_error = 'Failed to get contacts: $e'; _error = 'Failed to get contacts: $e';
notifyListeners(); notifyListeners();
} finally {
_isContactsSyncInProgress = false;
_contactsSyncCompleter = null;
} }
} }
@@ -749,18 +840,99 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_error = null;
_markSingleContactRequested(
publicKey,
source: ContactReceiveSource.requestedSingle,
);
await _activeService.getContactByKey(publicKey); await _activeService.getContactByKey(publicKey);
} catch (e) { } catch (e) {
final errorText = e.toString();
if (_error == 'Not found' || errorText.contains('Not found')) {
_error = 'Not found';
notifyListeners();
return;
}
_error = 'Failed to get contact: $e'; _error = 'Failed to get contact: $e';
debugPrint( debugPrint(
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync', '⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
); );
// Fallback to full contact sync if command not supported // Fallback to full contact sync if command not supported
_isContactsSyncInProgress = true;
await _activeService.getContacts(); await _activeService.getContacts();
notifyListeners(); notifyListeners();
} }
} }
Future<void> previewContact(Uint8List publicKey) async {
if (!_activeService.isConnected) {
return;
}
_prunePreviewContactMisses();
final keyHex = _publicKeyToHex(publicKey);
if (_previewContactMisses.containsKey(keyHex)) {
return;
}
try {
_markSingleContactRequested(
publicKey,
source: ContactReceiveSource.preview,
);
await _activeService.getContactByKey(publicKey);
} catch (e) {
debugPrint(
'⚠️ [Provider] Preview contact fetch failed for ${_publicKeyToHex(publicKey)}: $e',
);
}
}
ContactReceiveSource _classifyContactReceiveSource(Uint8List publicKey) {
_prunePendingSingleContactRequests();
final keyHex = _publicKeyToHex(publicKey);
if (_isContactsSyncInProgress) {
return ContactReceiveSource.sync;
}
final request = _pendingSingleContactRequests.remove(keyHex);
if (request != null &&
DateTime.now().difference(request.requestedAt) <=
_singleContactRequestWindow) {
return request.source;
}
return ContactReceiveSource.advert;
}
void _markSingleContactRequested(
Uint8List publicKey, {
required ContactReceiveSource source,
}) {
_prunePendingSingleContactRequests();
_pendingSingleContactRequests[_publicKeyToHex(publicKey)] =
_PendingContactRequest(source: source, requestedAt: DateTime.now());
}
void _prunePendingSingleContactRequests() {
if (_pendingSingleContactRequests.isEmpty) {
return;
}
final now = DateTime.now();
_pendingSingleContactRequests.removeWhere(
(_, request) =>
now.difference(request.requestedAt) > _singleContactRequestWindow,
);
}
void _prunePreviewContactMisses() {
if (_previewContactMisses.isEmpty) {
return;
}
final now = DateTime.now();
_previewContactMisses.removeWhere(
(_, timestamp) => now.difference(timestamp) > _previewContactMissTtl,
);
}
/// Sync all channels from device /// Sync all channels from device
Future<void> syncChannels({int? maxChannels}) async { Future<void> syncChannels({int? maxChannels}) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -890,9 +1062,8 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
// If not cached, query the device // If not cached, query the device (awaits the BLE response)
await _activeService.getChannel(channelIdx); await _activeService.getChannel(channelIdx);
await Future.delayed(const Duration(milliseconds: 100));
// Check again after query // Check again after query
if (getChannelInfo != null) { if (getChannelInfo != null) {
@@ -1086,6 +1257,10 @@ class ConnectionProvider with ChangeNotifier {
channelName: channelName, channelName: channelName,
secret: secretBytes, secret: secretBytes,
); );
// Reconcile local state from the device after the write completes,
// matching the delete flow so channel/contact lists refresh immediately.
await _activeService.getChannel(slotIdx);
} catch (e) { } catch (e) {
if (_isChannelRefreshTimeoutError(e)) { if (_isChannelRefreshTimeoutError(e)) {
debugPrint( debugPrint(
@@ -1137,11 +1312,9 @@ class ConnectionProvider with ChangeNotifier {
// behavior, then reconcile with an on-device refresh below. // behavior, then reconcile with an on-device refresh below.
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null); onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
// Small delay to allow the response to propagate // Refresh channel from device to reconcile UI state.
await Future.delayed(const Duration(milliseconds: 100)); // BLE commands are processed in order by firmware, so the
// getChannel response reflects the completed deletion.
// Refresh channels to update UI
// The empty channel will trigger removal via onChannelInfoReceived callback
await _activeService.getChannel(channelIdx); await _activeService.getChannel(channelIdx);
} catch (e) { } catch (e) {
_pendingDeletedChannelIndices.remove(channelIdx); _pendingDeletedChannelIndices.remove(channelIdx);
@@ -1181,6 +1354,7 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_error = null;
await _activeService.addOrUpdateContact(contact); await _activeService.addOrUpdateContact(contact);
} catch (e) { } catch (e) {
_error = 'Failed to add/update contact: $e'; _error = 'Failed to add/update contact: $e';
@@ -1188,6 +1362,22 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> importContactAdvert(Uint8List contactAdvertFrame) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
await _activeService.importContact(contactAdvertFrame);
} catch (e) {
_error = 'Failed to import contact: $e';
notifyListeners();
}
}
/// Send text message to contact /// Send text message to contact
/// ///
/// Returns true if the message was successfully sent to the BLE service. /// Returns true if the message was successfully sent to the BLE service.
@@ -1230,12 +1420,11 @@ class ConnectionProvider with ChangeNotifier {
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)', '⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
); );
debugPrint( debugPrint(
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...', '⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...',
); );
// Wait briefly for some ACKs to arrive, then proceed anyway // Wait for a slot to free up (delivery/timeout), max 500ms
// (User action shouldn't be blocked forever) await _messageDeliveryTracker.waitForSlot();
await Future.delayed(const Duration(milliseconds: 500));
if (_messageDeliveryTracker.shouldRateLimit) { if (_messageDeliveryTracker.shouldRateLimit) {
debugPrint( debugPrint(
@@ -1311,30 +1500,14 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt, attempt: retryAttempt,
); );
if (messageId != null) { // Clear pending operation — any ERR_CODE_NOT_FOUND has already been
Future.delayed(const Duration(milliseconds: 350), () { // handled synchronously by onContactNotFound before sendTextMessage returns.
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (effectiveContact != null) { if (effectiveContact != null) {
final operationId = contactPublicKey final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':'); .join(':');
// Use a small delay to allow error response to arrive before clearing _pendingSendOperations.remove(operationId);
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
} }
return true; return true;
@@ -1394,10 +1567,6 @@ class ConnectionProvider with ChangeNotifier {
); );
debugPrint(' trackSentChannelMessage completed'); debugPrint(' trackSentChannelMessage completed');
// Small delay to ensure the message is in the MessagesProvider list
// before we try to mark it as sent
await Future.delayed(const Duration(milliseconds: 50));
// Use a dummy ACK tag (0) and timeout (0) for channel messages // Use a dummy ACK tag (0) and timeout (0) for channel messages
// This will trigger the callback to mark the message as "sent" // This will trigger the callback to mark the message as "sent"
debugPrint(' Calling onMessageSent callback...'); debugPrint(' Calling onMessageSent callback...');
@@ -1636,6 +1805,40 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> discoverNodeType({
required int advertType,
bool prefixOnly = false,
int since = 0,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
final random = Random.secure();
final tagBytes = Uint8List.fromList(
List<int>.generate(4, (_) => random.nextInt(256)),
);
final payload = BytesBuilder(copy: false)
..addByte(_controlTypeNodeDiscoverReq | (prefixOnly ? 0x01 : 0x00))
..addByte(1 << advertType)
..add(tagBytes)
..add([
since & 0xFF,
(since >> 8) & 0xFF,
(since >> 16) & 0xFF,
(since >> 24) & 0xFF,
]);
try {
await _activeService.sendControlData(payload.toBytes());
} catch (e) {
_error = 'Failed to send node discovery request: $e';
notifyListeners();
}
}
/// Get device time from companion radio to detect clock drift /// Get device time from companion radio to detect clock drift
Future<void> getDeviceTime() async { Future<void> getDeviceTime() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -1866,12 +2069,96 @@ class ConnectionProvider with ChangeNotifier {
advertLocationPolicy: advertLocationPolicy, advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks, multiAcks: multiAcks,
); );
_deviceInfo = _deviceInfo.copyWith(
telemetryModes: telemetryModes,
advertLocPolicy: advertLocationPolicy,
multiAcks: multiAcks,
);
notifyListeners();
} catch (e) { } catch (e) {
_error = 'Failed to set other params: $e'; _error = 'Failed to set other params: $e';
notifyListeners(); notifyListeners();
} }
} }
Future<void> getAutoaddConfig() async {
if (_supportsAutoaddConfig == false) {
return;
}
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
final config = await _activeService.getAutoaddConfig();
_supportsAutoaddConfig = true;
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: config['autoAddUsers'] as bool?,
autoAddRepeaters: config['autoAddRepeaters'] as bool?,
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
);
notifyListeners();
} catch (e) {
if (_isUnsupportedAutoaddConfigError(e)) {
_supportsAutoaddConfig = false;
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: null,
autoAddRepeaters: null,
autoAddRoomServers: null,
autoAddSensors: null,
autoAddOverwriteOldest: null,
);
notifyListeners();
return;
}
if (_isTransientAutoaddConfigError(e)) {
debugPrint('Ignoring transient auto-add config error: $e');
return;
}
_error = 'Failed to get auto-add config: $e';
notifyListeners();
}
}
Future<void> setAutoaddConfig({
required bool autoAddUsers,
required bool autoAddRepeaters,
required bool autoAddRoomServers,
required bool autoAddSensors,
required bool overwriteOldest,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.setAutoaddConfig(
autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest,
);
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
autoAddOverwriteOldest: overwriteOldest,
);
notifyListeners();
} catch (e) {
_error = 'Failed to set auto-add config: $e';
notifyListeners();
}
}
/// Request fresh device info (triggers SelfInfo response) /// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return; if (_isSpectrumScanActive) return;
@@ -1884,14 +2171,31 @@ class ConnectionProvider with ChangeNotifier {
try { try {
// The device query command triggers a SelfInfo response // The device query command triggers a SelfInfo response
await _activeService.refreshDeviceInfo(); await _activeService.refreshDeviceInfo();
// Also request allowed repeat frequencies (firmware v9+, no-op on older firmware) if (_supportsAutoaddConfig != false) {
await _activeService.getAllowedRepeatFreq(); unawaited(getAutoaddConfig());
}
try {
await _activeService.getAllowedRepeatFreq();
} catch (_) {
// Older firmware may not expose repeat frequency ranges.
}
} catch (e) { } catch (e) {
_error = 'Failed to refresh device info: $e'; _error = 'Failed to refresh device info: $e';
notifyListeners(); notifyListeners();
} }
} }
bool _isUnsupportedAutoaddConfigError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('unsupported command') ||
message.contains('unsupported');
}
bool _isTransientAutoaddConfigError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('illegal argument');
}
/// Request battery and storage information /// Request battery and storage information
/// ///
/// Queries the companion radio for: /// Queries the companion radio for:
@@ -2175,6 +2479,28 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<({int tag, int suggestedTimeoutMs})?> sendAnonRequest({
required Uint8List contactPublicKey,
required Uint8List requestData,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
return await _activeService.sendAnonRequest(
contactPublicKey: contactPublicKey,
requestData: requestData,
);
} catch (e) {
_error = 'Failed to send anonymous request: $e';
notifyListeners();
return null;
}
}
/// Reset routing path for a contact /// Reset routing path for a contact
/// ///
/// Clears the learned path to a contact, forcing the next message to use /// Clears the learned path to a contact, forcing the next message to use
@@ -2235,7 +2561,7 @@ class ConnectionProvider with ChangeNotifier {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
return; throw Exception(_error);
} }
try { try {
@@ -2243,6 +2569,7 @@ class ConnectionProvider with ChangeNotifier {
} catch (e) { } catch (e) {
_error = 'Failed to remove contact: $e'; _error = 'Failed to remove contact: $e';
notifyListeners(); notifyListeners();
rethrow;
} }
} }
@@ -2262,117 +2589,13 @@ class ConnectionProvider with ChangeNotifier {
return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix); return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix);
} }
// ============================================================================
// SSE Server Methods
// ============================================================================
/// Start SSE server to share BLE device with multiple clients
Future<void> startSseServer(SseServerConfig config) async {
if (_sseServer.isRunning) {
debugPrint('⚠️ [ConnectionProvider] SSE server already running');
return;
}
try {
debugPrint('🚀 [ConnectionProvider] Starting SSE server...');
_sseServerConfig = config;
// Wire up callbacks
_sseServer.onSendMessage = (recipientPublicKey, text) async {
// Convert hex string to Uint8List
final bytes = <int>[];
for (int i = 0; i < recipientPublicKey.length; i += 2) {
bytes.add(
int.parse(recipientPublicKey.substring(i, i + 2), radix: 16),
);
}
return await sendTextMessage(
contactPublicKey: Uint8List.fromList(bytes),
text: text,
);
};
_sseServer.onSendChannelMessage = (channelIdx, text) async {
await sendChannelMessage(channelIdx: channelIdx, text: text);
};
_sseServer.onSyncContacts = () async {
await getContacts();
};
await _sseServer.startServer(config);
// Set initial device name
_sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName);
_connectionMode = ConnectionMode.sseServer;
notifyListeners();
debugPrint('✅ [ConnectionProvider] SSE server started');
} catch (e) {
_error = 'Failed to start SSE server: $e';
debugPrint('❌ [ConnectionProvider] Failed to start SSE server: $e');
notifyListeners();
rethrow;
}
}
/// Stop SSE server
Future<void> stopSseServer() async {
if (!_sseServer.isRunning) {
return;
}
debugPrint('🛑 [ConnectionProvider] Stopping SSE server...');
await _sseServer.stopServer();
if (_connectionMode == ConnectionMode.sseServer) {
_connectionMode = ConnectionMode.ble;
}
notifyListeners();
debugPrint('✅ [ConnectionProvider] SSE server stopped');
}
/// Broadcast message to SSE clients (call this when receiving messages from BLE)
void broadcastMessageToSseClients(Message message) {
if (_sseServer.isRunning) {
_sseServer.broadcastMessage(message);
}
}
/// Broadcast contact to SSE clients (call this when receiving contacts from BLE)
void broadcastContactToSseClients(Contact contact) {
if (_sseServer.isRunning) {
_sseServer.broadcastContact(contact);
}
}
/// Get SSE server status
bool get isSseServerRunning => _sseServer.isRunning;
/// Get number of connected SSE clients
int get sseClientCount => _sseServer.connectedClients;
/// Set connection mode
void setConnectionMode(ConnectionMode mode) {
_connectionMode = mode;
notifyListeners();
}
/// Update SSE server configuration
void updateSseServerConfig(SseServerConfig config) {
_sseServerConfig = config;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_rxActivityTimer?.cancel(); _rxActivityTimer?.cancel();
_txActivityTimer?.cancel(); _txActivityTimer?.cancel();
_stopAckCleanupTimer();
_bleService.dispose(); _bleService.dispose();
_tcpService?.dispose(); _tcpService?.dispose();
_sseServer.stopServer();
super.dispose(); super.dispose();
} }
} }

View File

@@ -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 {
@@ -64,6 +130,10 @@ class ContactsProvider with ChangeNotifier {
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() {
@@ -83,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 =
@@ -99,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',
); );
@@ -131,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 =
@@ -147,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',
); );
@@ -202,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;
} }
} }
@@ -226,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)
@@ -332,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);
@@ -440,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');
} }
@@ -471,6 +593,7 @@ class ContactsProvider with ChangeNotifier {
); );
} }
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -503,7 +626,6 @@ class ContactsProvider with ChangeNotifier {
if (existingContact == null) { if (existingContact == null) {
var newContact = incomingContact.copyWith( var newContact = incomingContact.copyWith(
isNew: true, isNew: true,
nameOverride: existingContact?.nameOverride,
telemetry: mergedTelemetry, telemetry: mergedTelemetry,
outPathLen: outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen, retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
@@ -753,10 +875,19 @@ class ContactsProvider with ChangeNotifier {
telemetry.gpsLocation, telemetry.gpsLocation,
)) { )) {
debugPrint( debugPrint(
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps', ' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps',
); );
} }
telemetry = mergedTelemetry; telemetry = ContactTelemetry(
gpsLocation: telemetry.gpsLocation,
batteryPercentage: mergedTelemetry.batteryPercentage,
batteryMilliVolts: mergedTelemetry.batteryMilliVolts,
temperature: mergedTelemetry.temperature,
timestamp: mergedTelemetry.timestamp,
humidity: mergedTelemetry.humidity,
pressure: mergedTelemetry.pressure,
extraSensorData: telemetry.extraSensorData,
);
} }
// Update contact with new telemetry AND last seen time // Update contact with new telemetry AND last seen time
@@ -871,10 +1002,10 @@ class ContactsProvider with ChangeNotifier {
// last known reading for any field that is omitted in the incoming update. // last known reading for any field that is omitted in the incoming update.
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation); final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation); final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{ final mergedExtraSensorData = _mergeExtraSensorData(
...?existingTelemetry?.extraSensorData, existingTelemetry?.extraSensorData,
...?incomingTelemetry.extraSensorData, incomingTelemetry.extraSensorData,
}; );
return ContactTelemetry( return ContactTelemetry(
gpsLocation: incomingGps ?? previousGps, gpsLocation: incomingGps ?? previousGps,
@@ -895,6 +1026,49 @@ class ContactsProvider with ChangeNotifier {
); );
} }
Map<String, dynamic> _mergeExtraSensorData(
Map<String, dynamic>? existingExtraSensorData,
Map<String, dynamic>? incomingExtraSensorData,
) {
final merged = <String, dynamic>{...?existingExtraSensorData};
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
return merged;
}
final incomingMetricFamilies = incomingExtraSensorData.keys
.map(_telemetryMetricFamilyForKey)
.whereType<String>()
.toSet();
if (incomingMetricFamilies.isNotEmpty) {
merged.removeWhere((key, _) {
final family = _telemetryMetricFamilyForKey(key);
return family != null && incomingMetricFamilies.contains(family);
});
}
merged.addAll(incomingExtraSensorData);
return merged;
}
String? _telemetryMetricFamilyForKey(String key) {
const sourcePrefix = '__source_channel:';
if (key.startsWith(sourcePrefix)) {
return key.substring(sourcePrefix.length);
}
final separatorIndex = key.lastIndexOf('_');
if (separatorIndex <= 0 || separatorIndex == key.length - 1) {
return key;
}
final suffix = key.substring(separatorIndex + 1);
if (int.tryParse(suffix) == null) {
return key;
}
return key.substring(0, separatorIndex);
}
int _coordinateToAdvertMicrodegrees(double coordinate) { int _coordinateToAdvertMicrodegrees(double coordinate) {
return (coordinate * 1e6).round(); return (coordinate * 1e6).round();
} }
@@ -908,7 +1082,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;
} }
@@ -1063,39 +1237,132 @@ class ContactsProvider with ChangeNotifier {
} }
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80). /// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts. /// Excludes only self key; known contacts still keep a discovery entry.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) { bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) { if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return; 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
@@ -1144,7 +1411,9 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() { void clearContacts() {
_contacts.clear(); _contacts.clear();
_pendingAdverts.clear(); _pendingAdverts.clear();
_ensurePublicChannelExists();
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -1167,6 +1436,7 @@ class ContactsProvider with ChangeNotifier {
_contacts.remove(publicKeyHex); _contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex);
_persistContacts(); _persistContacts();
_persistPendingAdverts();
notifyListeners(); notifyListeners();
} }
@@ -1175,11 +1445,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,
}; };

View File

@@ -22,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;
@@ -53,6 +54,7 @@ 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;
@@ -61,6 +63,7 @@ class DrawingProvider with ChangeNotifier {
Future<void> initialize() async { Future<void> initialize() async {
await _loadPreferences(); await _loadPreferences();
await _loadDrawings(); await _loadDrawings();
_isInitialized = true;
} }
/// Set drawing mode /// Set drawing mode

View File

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

View File

@@ -18,6 +18,8 @@ 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); static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
@@ -28,6 +30,8 @@ class MessagesProvider with ChangeNotifier {
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 = {};
@@ -549,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(
@@ -580,8 +593,21 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot; _messageContactLocations[existingId] = contactLocationSnapshot;
} }
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
if (receptionDetailsSnapshot != null) { if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot; 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(); _persistMessages();
return; // Skip duplicate return; // Skip duplicate
@@ -692,7 +718,7 @@ class MessagesProvider with ChangeNotifier {
incomingSenderName != null && incomingSenderName != null &&
existingSenderName == incomingSenderName) { existingSenderName == incomingSenderName) {
if (!existing.isSentMessage && !message.isSentMessage) { if (!existing.isSentMessage && !message.isSentMessage) {
return true; return withinChannelRepeatWindow;
} }
if (!withinChannelRepeatWindow) { if (!withinChannelRepeatWindow) {
@@ -715,7 +741,8 @@ class MessagesProvider with ChangeNotifier {
return false; return false;
} }
return true; // System messages and other types: never deduplicate by scope alone.
return false;
} }
/// Add multiple messages /// Add multiple messages
@@ -725,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 (_findDuplicateMessageIndex(enhancedMessage) != -1) { 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
} }
@@ -973,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;
} }
} }
@@ -1028,6 +1100,7 @@ class MessagesProvider with ChangeNotifier {
for (final messageId in messageIdsToRemove) { for (final messageId in messageIdsToRemove) {
_timeoutTimers[messageId]?.cancel(); _timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId); _timeoutTimers.remove(messageId);
_clearChannelSendWarning(messageId);
_messageContactMap.remove(messageId); _messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId); _groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId); _messageContactLocations.remove(messageId);
@@ -1062,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);
@@ -1245,11 +1347,7 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages /// Clear all messages
void clearMessages() { void clearMessages() {
for (final timer in _channelEchoWarningTimers.values) { _cancelAllTimers();
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear(); _removedSarMarkerIds.clear();
@@ -1257,6 +1355,14 @@ class MessagesProvider with ChangeNotifier {
_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)); unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
@@ -1272,11 +1378,7 @@ class MessagesProvider with ChangeNotifier {
/// Clear all data /// Clear all data
void clearAll() { void clearAll() {
for (final timer in _channelEchoWarningTimers.values) { _cancelAllTimers();
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear(); _removedSarMarkerIds.clear();
@@ -1284,6 +1386,14 @@ class MessagesProvider with ChangeNotifier {
_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)); unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
@@ -1964,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(),
@@ -2323,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,
@@ -2410,7 +2521,19 @@ class MessagesProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
// Cancel all pending timeout timers _cancelAllTimers();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
/// Cancel all pending timers (timeout + echo warning).
void _cancelAllTimers() {
for (final timer in _timeoutTimers.values) { for (final timer in _timeoutTimers.values) {
timer.cancel(); timer.cancel();
} }
@@ -2420,14 +2543,6 @@ class MessagesProvider with ChangeNotifier {
} }
_channelEchoWarningTimers.clear(); _channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear(); _channelEchoWarningMessageIds.clear();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
} }
void _rememberCompletedAck(int ackCode) { void _rememberCompletedAck(int ackCode) {

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_provider.dart'; import 'contacts_provider.dart';
@@ -15,6 +16,19 @@ 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,
5,
15,
30,
60,
360,
720,
1440,
];
static const Set<String> _defaultVisibleFields = <String>{ static const Set<String> _defaultVisibleFields = <String>{
'voltage', 'voltage',
'battery', 'battery',
@@ -23,6 +37,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 +54,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 +81,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 +107,49 @@ 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 = _normalizeAutoRefreshMinutes(
(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,10 +190,56 @@ 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,
); );
Set<String> effectiveVisibleFieldsFor(
String publicKeyHex,
Iterable<String> availableFieldKeys,
) {
final storedVisibleFields =
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
return Set<String>.unmodifiable(storedVisibleFields);
}
return Set<String>.unmodifiable(<String>{
...storedVisibleFields,
...availableFieldKeys,
});
}
bool showsField(String publicKeyHex, String fieldKey) => bool showsField(String publicKeyHex, String fieldKey) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey); visibleFieldsFor(publicKeyHex).contains(fieldKey);
@@ -131,6 +249,104 @@ 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 = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
if (currentMinutes == normalizedMinutes) {
return;
}
if (normalizedMinutes == 0) {
_autoRefreshMinutesBySensor.remove(publicKeyHex);
} else {
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
}
await _persistAutoRefreshMinutes();
notifyListeners();
}
static int _normalizeAutoRefreshMinutes(int minutes) {
if (minutes <= 0) {
return 0;
}
if (supportedAutoRefreshIntervals.contains(minutes)) {
return minutes;
}
final positiveIntervals = supportedAutoRefreshIntervals.where(
(value) => value > 0,
);
var bestMatch = positiveIntervals.first;
var bestDistance = (minutes - bestMatch).abs();
for (final interval in positiveIntervals.skip(1)) {
final distance = (minutes - interval).abs();
if (distance < bestDistance) {
bestMatch = interval;
bestDistance = distance;
}
}
return bestMatch;
}
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 +356,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 +374,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey); visibleFields.remove(fieldKey);
} }
await _persistVisibleMetrics(); await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners(); notifyListeners();
} }
@@ -166,11 +394,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)) {
@@ -178,25 +457,69 @@ class SensorsProvider with ChangeNotifier {
} }
_watchedSensorKeys.add(contact.publicKeyHex); _watchedSensorKeys.add(contact.publicKeyHex);
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
await _persistWatchedSensors(); await _persistWatchedSensors();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from( _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
_defaultVisibleFields,
);
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2}; _fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
contact.publicKeyHex,
initialVisibleFields,
);
await _persistVisibleMetrics(); await _persistVisibleMetrics();
await _persistFieldSpans(); await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
notifyListeners(); notifyListeners();
} }
bool _shouldAutoIncludeAvailableFields(
String publicKeyHex,
Set<String> storedVisibleFields,
) {
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
return false;
}
final storedOrder = _metricOrderBySensor[publicKeyHex];
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
return false;
}
final storedLabels = _metricLabelsBySensor[publicKeyHex];
if (storedLabels != null && storedLabels.isNotEmpty) {
return false;
}
final storedSpans = _fieldSpansBySensor[publicKeyHex];
if (storedSpans != null &&
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
return false;
}
return true;
}
Set<String> _initialVisibleFieldsForContact(Contact contact) {
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
}
Future<void> removeSensor(String publicKeyHex) async { Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex); _watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex); _refreshStates.remove(publicKeyHex);
_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 +527,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 +563,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 +597,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>[];

View File

@@ -0,0 +1,386 @@
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);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Import Contact')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.person_add_alt_1_outlined,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Import a shared contact advert',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 14),
Text(
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_ImportHintChip(
icon: Icons.link_outlined,
label: 'Accepts share links',
),
_ImportHintChip(
icon: Icons.code_outlined,
label: 'Supports raw hex',
),
_ImportHintChip(
icon: Icons.content_paste_go_outlined,
label: 'Clipboard-friendly',
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert payload',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'You can paste the full share link or only the hex payload.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
TextField(
controller: _advertController,
minLines: 5,
maxLines: 9,
onChanged: (_) {
if (_validationError != null || _importSucceeded) {
setState(() {
_importSucceeded = false;
_validationError = null;
});
}
},
decoration: InputDecoration(
labelText: 'Contact advert',
hintText: 'Paste a share link or hex advert',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
errorText: _validationError,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
normalized == null
? 'No valid advert detected yet'
: 'Advert size: ${normalized.length ~/ 2} bytes',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: _isImporting ? null : _pasteFromClipboard,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Paste'),
),
],
),
],
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: (_isImporting || _importSucceeded)
? null
: _importContact,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
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
? 'Imported'
: 'Import Contact',
),
),
const SizedBox(height: 12),
if (_importSucceeded)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.primary.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Contact imported. Paste another advert or edit the current one to import again.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
class _ImportHintChip extends StatelessWidget {
final IconData icon;
final String label;
const _ImportHintChip({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.labelMedium),
],
),
);
}
}

View File

@@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart'; import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget { class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
@@ -34,11 +35,10 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingPendingBatch = false;
final Map<ContactSection, String> _sectionFilters = { final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '', ContactSection.teamMembers: '',
ContactSection.repeaters: '', ContactSection.repeaters: '',
ContactSection.sensors: '',
ContactSection.rooms: '', ContactSection.rooms: '',
ContactSection.channels: '', ContactSection.channels: '',
}; };
@@ -46,6 +46,7 @@ class _ContactsTabState extends State<ContactsTab> {
final Map<ContactSection, ContactSortMode> _sortModes = { final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.sensors: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen, ContactSection.rooms: ContactSortMode.lastSeen,
}; };
@@ -99,57 +100,6 @@ class _ContactsTabState extends State<ContactsTab> {
await _getCurrentLocation(); await _getCurrentLocation();
} }
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
void _schedulePendingAdvertResolution(
List<PendingAdvert> pendingAdverts,
ConnectionProvider connectionProvider,
) {
if (_isResolvingPendingBatch ||
!connectionProvider.deviceInfo.isConnected ||
pendingAdverts.isEmpty) {
return;
}
final advertsToResolve = pendingAdverts
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
.toList();
if (advertsToResolve.isEmpty) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted || _isResolvingPendingBatch) return;
_isResolvingPendingBatch = true;
try {
for (final advert in advertsToResolve) {
if (!mounted) break;
await _handleResolveAdvert(advert);
}
} finally {
_isResolvingPendingBatch = false;
}
});
}
/// Calculate distance between two points in meters /// Calculate distance between two points in meters
double _calculateDistanceInMeters( double _calculateDistanceInMeters(
double lat1, double lat1,
@@ -181,15 +131,6 @@ class _ContactsTabState extends State<ContactsTab> {
} }
} }
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
List<Contact> _filterContactsForSection( List<Contact> _filterContactsForSection(
List<Contact> contacts, List<Contact> contacts,
ContactSection section, ContactSection section,
@@ -449,6 +390,12 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
Future<void> _openAddContactScreen(BuildContext context) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const AddContactScreen()));
}
Future<void> _showDeleteChannelDialog( Future<void> _showDeleteChannelDialog(
BuildContext context, BuildContext context,
Contact channel, Contact channel,
@@ -593,7 +540,6 @@ class _ContactsTabState extends State<ContactsTab> {
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>(); final messagesProvider = context.watch<MessagesProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts( final allChatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers, ContactSection.teamMembers,
@@ -602,6 +548,10 @@ class _ContactsTabState extends State<ContactsTab> {
contactsProvider.repeaters, contactsProvider.repeaters,
ContactSection.repeaters, ContactSection.repeaters,
); );
final allSensors = _sortContacts(
contactsProvider.sensorContacts,
ContactSection.sensors,
);
final allRooms = _sortContacts( final allRooms = _sortContacts(
contactsProvider.rooms, contactsProvider.rooms,
ContactSection.rooms, ContactSection.rooms,
@@ -643,6 +593,19 @@ class _ContactsTabState extends State<ContactsTab> {
final showRepeatersOthersGroup = final showRepeatersOthersGroup =
visibleSavedRepeaterGroups.length > 1 && visibleSavedRepeaterGroups.length > 1 &&
ungroupedRepeaters.isNotEmpty; ungroupedRepeaters.isNotEmpty;
final sensors = _filterContactsForSection(
allSensors,
ContactSection.sensors,
);
final savedSensorGroups = _buildSavedGroupsForSection(
contactsProvider,
allSensors,
ContactSection.sensors,
);
final visibleSavedSensorGroups =
_showSavedGroupsForSection(ContactSection.sensors)
? savedSensorGroups
: const <_RenderedSavedGroup>[];
final rooms = _filterContactsForSection( final rooms = _filterContactsForSection(
allRooms, allRooms,
ContactSection.rooms, ContactSection.rooms,
@@ -669,36 +632,18 @@ class _ContactsTabState extends State<ContactsTab> {
_showSavedGroupsForSection(ContactSection.channels) _showSavedGroupsForSection(ContactSection.channels)
? savedChannelGroups ? savedChannelGroups
: const <_RenderedSavedGroup>[]; : const <_RenderedSavedGroup>[];
final showTeamMembersSection = final showTeamMembersSection = allChatContacts.isNotEmpty;
allChatContacts.isNotEmpty && final showRepeatersSection = allRepeaters.isNotEmpty;
(!_sectionHasActiveFilter(ContactSection.teamMembers) || final showSensorsSection = allSensors.isNotEmpty;
chatContacts.isNotEmpty || final showRoomsSection = allRooms.isNotEmpty;
visibleSavedTeamGroups.isNotEmpty); final showChannelsSection = allChannels.isNotEmpty;
final showRepeatersSection =
allRepeaters.isNotEmpty &&
(!_sectionHasActiveFilter(ContactSection.repeaters) ||
repeaters.isNotEmpty ||
visibleSavedRepeaterGroups.isNotEmpty);
final showRoomsSection =
allRooms.isNotEmpty &&
(!_sectionHasActiveFilter(ContactSection.rooms) ||
rooms.isNotEmpty ||
visibleSavedRoomGroups.isNotEmpty);
final showChannelsSection =
!_sectionHasActiveFilter(ContactSection.channels) ||
filteredChannels.isNotEmpty ||
visibleSavedChannelGroups.isNotEmpty;
final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
// Check if there are any displayable contacts // Check if there are any displayable contacts
final hasDisplayableContacts = final hasDisplayableContacts =
allChatContacts.isNotEmpty || allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty || allRepeaters.isNotEmpty ||
allSensors.isNotEmpty ||
allRooms.isNotEmpty || allRooms.isNotEmpty ||
allChannels.isNotEmpty || allChannels.isNotEmpty;
pendingAdverts.isNotEmpty;
if (!hasDisplayableContacts) { if (!hasDisplayableContacts) {
return Center( return Center(
@@ -721,6 +666,18 @@ class _ContactsTabState extends State<ContactsTab> {
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
Padding(
padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: const Icon(Icons.person_add_alt_1_outlined),
label: const Text('Import Contact'),
),
),
], ],
), ),
); );
@@ -761,12 +718,16 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedTeamGroups, visibleSavedTeamGroups,
ContactSection.teamMembers, ContactSection.teamMembers,
), ),
..._buildContactSectionItems( if (chatContacts.isEmpty &&
_excludeGroupedContacts( _sectionHasActiveFilter(ContactSection.teamMembers))
chatContacts, _buildNoFilterResults(context)
visibleSavedTeamGroups, else
..._buildContactSectionItems(
_excludeGroupedContacts(
chatContacts,
visibleSavedTeamGroups,
),
), ),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -799,7 +760,10 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedRepeaterGroups, visibleSavedRepeaterGroups,
ContactSection.repeaters, ContactSection.repeaters,
), ),
if (showRepeatersOthersGroup) if (repeaters.isEmpty &&
_sectionHasActiveFilter(ContactSection.repeaters))
_buildNoFilterResults(context)
else if (showRepeatersOthersGroup)
_InferredContactGroupCard( _InferredContactGroupCard(
label: 'Others', label: 'Others',
contacts: ungroupedRepeaters, contacts: ungroupedRepeaters,
@@ -819,6 +783,36 @@ class _ContactsTabState extends State<ContactsTab> {
const Divider(height: 32), const Divider(height: 32),
], ],
// Sensors
if (showSensorsSection) ...[
_SectionHeader(
title: 'Sensors',
count: sensors.length,
icon: Icons.sensors,
trailing: _buildSortMenu(context, ContactSection.sensors),
),
_buildSectionFilterField(
context,
ContactSection.sensors,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedSensorGroups,
ContactSection.sensors,
),
if (sensors.isEmpty &&
_sectionHasActiveFilter(ContactSection.sensors))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(
sensors,
visibleSavedSensorGroups,
),
),
const Divider(height: 32),
],
// Rooms // Rooms
if (showRoomsSection) ...[ if (showRoomsSection) ...[
_SectionHeader( _SectionHeader(
@@ -836,30 +830,13 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedRoomGroups, visibleSavedRoomGroups,
ContactSection.rooms, ContactSection.rooms,
), ),
..._buildContactSectionItems( if (rooms.isEmpty &&
_excludeGroupedContacts(rooms, visibleSavedRoomGroups), _sectionHasActiveFilter(ContactSection.rooms))
), _buildNoFilterResults(context)
const Divider(height: 32), else
], ..._buildContactSectionItems(
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
// Pending adverts are kept below resolved sections while we load details.
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_search,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
), ),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -879,7 +856,10 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedChannelGroups, visibleSavedChannelGroups,
ContactSection.channels, ContactSection.channels,
), ),
if (filteredChannels.isNotEmpty) ...[ if (filteredChannels.isEmpty &&
_sectionHasActiveFilter(ContactSection.channels))
_buildNoFilterResults(context)
else ...[
..._excludeGroupedContacts( ..._excludeGroupedContacts(
filteredChannels, filteredChannels,
visibleSavedChannelGroups, visibleSavedChannelGroups,
@@ -901,16 +881,36 @@ class _ContactsTabState extends State<ContactsTab> {
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
child: OutlinedButton.icon( child: Row(
onPressed: () => _showAddChannelDialog(context), children: [
icon: const Icon(Icons.add_circle_outline), Expanded(
label: Text(l10n.addChannel), child: OutlinedButton.icon(
style: OutlinedButton.styleFrom( onPressed: () => _openAddContactScreen(context),
padding: const EdgeInsets.symmetric( icon: const Icon(Icons.person_add_alt_1_outlined),
horizontal: 24, label: const Text('Import Contact'),
vertical: 12, style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
), ),
), const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: const Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
],
), ),
), ),
], ],
@@ -921,6 +921,18 @@ class _ContactsTabState extends State<ContactsTab> {
); );
} }
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
List<Widget> _buildContactSectionItems( List<Widget> _buildContactSectionItems(
List<Contact> contacts, { List<Contact> contacts, {
bool compact = false, bool compact = false,
@@ -1250,47 +1262,7 @@ class _ContactsTabState extends State<ContactsTab> {
enum ContactSortMode { lastSeen, distance } enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels } enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
final bool isResolving;
final VoidCallback onResolve;
const _PendingAdvertTile({
required this.advert,
required this.subtitle,
required this.isResolving,
required this.onResolve,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
title: Text(
advert.shortDisplayKey,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(subtitle),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
onPressed: onResolve,
),
),
);
}
}
class _RenderedSavedGroup { class _RenderedSavedGroup {
final SavedContactGroup group; final SavedContactGroup group;

View File

@@ -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();
@@ -375,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(() {
@@ -425,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,
); );
@@ -436,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,
); );
@@ -537,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();
@@ -687,6 +851,205 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
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;
@@ -781,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.',
@@ -1230,10 +1751,60 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
const SizedBox(height: 16), 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( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: _confirmFactoryReset, onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmFactoryReset,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: colorScheme.error, backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError, foregroundColor: colorScheme.onError,

View 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),
),
),
],
],
);
}
}

View File

@@ -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(
@@ -768,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) {

View File

@@ -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,
),
), ),
], ],
), ),
@@ -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,
@@ -641,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,
),
),
],
), ),
); );
} }
@@ -748,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(
@@ -856,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>(
@@ -886,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,
@@ -909,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,
@@ -940,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;
@@ -958,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,

View File

@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer; Timer? _channelReadTimer;
String? _pendingChannelReadKey; String? _pendingChannelReadKey;
TextEditingValue _lastComposerValue = const TextEditingValue();
bool _isMentionPickerOpen = false;
bool _suppressMentionTrigger = false; bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state // Message destination state
String _destinationType = String _destinationType =
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_lastComposerValue = _textController.value;
_textController.addListener(_handleComposerChanged); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceSettings(); _loadVoiceSettings();
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.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();
@@ -161,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
@@ -195,25 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
void _handleComposerChanged() { void _handleComposerChanged() {
final previousValue = _lastComposerValue;
final currentValue = _textController.value;
_lastComposerValue = currentValue;
_updateCharacterCount(); _updateCharacterCount();
if (_suppressMentionTrigger || _isMentionPickerOpen) { if (_suppressMentionTrigger) {
return; return;
} }
final mentionTriggerRange = _getMentionTriggerRange( _updateMentionSuggestions(_textController.value);
previousValue: previousValue,
currentValue: currentValue,
);
if (mentionTriggerRange == null) {
return;
}
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
} }
void _updateCharacterCount() { void _updateCharacterCount() {
@@ -222,43 +216,112 @@ class _MessagesTabState extends State<MessagesTab> {
}); });
} }
TextRange? _getMentionTriggerRange({ void _handleFocusChanged() {
required TextEditingValue previousValue, if (!_focusNode.hasFocus) {
required TextEditingValue currentValue, _clearMentionSuggestions();
}) { return;
if (!previousValue.selection.isValid || }
!currentValue.selection.isValid ||
!previousValue.selection.isCollapsed || if (_suppressMentionTrigger) {
!currentValue.selection.isCollapsed) { 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; return null;
} }
final previousOffset = previousValue.selection.baseOffset; final cursorOffset = value.selection.baseOffset;
final currentOffset = currentValue.selection.baseOffset; if (cursorOffset <= 0) {
if (previousOffset < 0 || currentOffset < 0) {
return null; return null;
} }
if (currentValue.text.length != previousValue.text.length + 1 || final text = value.text;
currentOffset != previousOffset + 1) { final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null; return null;
} }
if (currentValue.text.substring(0, previousOffset) != final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
previousValue.text.substring(0, previousOffset)) { if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null; return null;
} }
if (currentValue.text.substring(currentOffset) != final query = text.substring(triggerStart + 1, cursorOffset);
previousValue.text.substring(previousOffset)) { if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null; return null;
} }
if (currentValue.text[previousOffset] != '@') { return TextRange(start: triggerStart, end: cursorOffset);
return null; }
}
return TextRange(start: previousOffset, end: currentOffset); 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 =>
@@ -378,56 +441,6 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
final contactsProvider = context.read<ContactsProvider>();
final contacts = contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.toList();
if (contacts.isEmpty || !mounted) {
return;
}
_isMentionPickerOpen = true;
Contact? selectedContact;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => RecipientSelectorSheet(
contacts: contacts,
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: {
for (final contact in contacts) contact.publicKeyHex: 0,
},
currentDestinationType: null,
currentRecipientPublicKey: null,
showAllOption: false,
onSelect: (_, recipient) {
selectedContact = recipient;
},
),
);
_isMentionPickerOpen = false;
if (!mounted) {
return;
}
if (selectedContact != null) {
_insertReplyMention(
selectedContact!.displayName,
replacementRange: triggerRange,
);
}
_focusNode.requestFocus();
}
/// Handle recipient selection /// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async { Future<void> _onRecipientSelected(String type, Contact? recipient) async {
setState(() { setState(() {
@@ -489,15 +502,24 @@ class _MessagesTabState extends State<MessagesTab> {
selection: TextSelection.collapsed(offset: nextOffset), selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty, composing: TextRange.empty,
); );
_lastComposerValue = _textController.value;
_suppressMentionTrigger = false; _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;
@@ -522,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();
} }
@@ -2002,7 +2044,9 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
_syncChannelAutoReadTimer(messagesProvider); _syncChannelAutoReadTimer(messagesProvider);
final messages = _getFilteredMessages(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;
@@ -2040,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions, onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector, onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -25,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';
@@ -77,6 +77,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _rotateMapWithHeading = false; bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false; bool _showMapDebugInfo = false;
bool _openMapInFullscreen = 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;
@@ -99,6 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences(); _loadMapPreferences();
_loadNotificationPreferences();
} }
@override @override
@@ -136,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;
@@ -187,8 +206,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_rotateMapWithHeading = _rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false; _showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false; _openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
}); });
@@ -1002,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>(
@@ -1047,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'),
@@ -1224,9 +1311,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, drawingProvider, child) => SwitchListTile( builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined), secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'), title: const Text('Show SAR markers'),
subtitle: const Text( subtitle: const Text('Display SAR markers on the main map'),
'Display SAR markers on the main map',
),
value: drawingProvider.showSarMarkers, value: drawingProvider.showSarMarkers,
onChanged: (value) { onChanged: (value) {
drawingProvider.toggleSarMarkers(); drawingProvider.toggleSarMarkers();
@@ -1468,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(
@@ -1795,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
Switch.adaptive( Switch(
value: _showCurrentImagePreview, value: _showCurrentImagePreview,
onChanged: (value) { onChanged: (value) {
setState(() => _showCurrentImagePreview = value); setState(() => _showCurrentImagePreview = value);

View File

@@ -5,6 +5,76 @@ 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 _lppGust = 137;
static const int _lppDewPoint = 138;
static const int _lppRain = 139;
static const int _lppSwitch = 142;
static const int _lppBinaryBool = 143;
static const int _lppBinaryPowerSwitch = 144;
static const int _lppBinaryOpen = 145;
static const int _lppBinaryBatteryLow = 146;
static const int _lppBinaryCharging = 147;
static const int _lppBinaryCarbonMonoxide = 148;
static const int _lppBinaryCold = 149;
static const int _lppBinaryConnectivity = 150;
static const int _lppBinaryDoor = 151;
static const int _lppBinaryGarageDoor = 152;
static const int _lppBinaryGas = 153;
static const int _lppBinaryHeat = 154;
static const int _lppBinaryLight = 155;
static const int _lppBinaryLock = 156;
static const int _lppBinaryMoisture = 157;
static const int _lppBinaryMotion = 158;
static const int _lppBinaryMoving = 159;
static const int _lppBinaryOccupancy = 160;
static const int _lppBinaryPlug = 161;
static const int _lppBinaryPresence = 162;
static const int _lppBinaryProblem = 163;
static const int _lppBinaryRunning = 164;
static const int _lppBinarySafety = 165;
static const int _lppBinarySmoke = 166;
static const int _lppBinarySound = 167;
static const int _lppBinaryTamper = 168;
static const int _lppBinaryVibration = 169;
static const int _lppBinaryWindow = 170;
static const int _lppButtonEvent = 171;
static const int _lppDimmer = 172;
static const int _lppUv = 173;
static const int _lppLightLevel = 174;
static const int _lppPm25 = 175;
static const int _lppPm10 = 176;
static const int _lppCo2 = 177;
static const int _lppTvoc = 178;
static const int _lppRpm = 179;
static const int _lppConductivity = 180;
static const int _lppRotation = 181;
static const int _lppDuration = 182;
static const int _lppAcceleration = 183;
static const int _lppGyroRate = 184;
static const int _lppVolume = 185;
static const int _lppFlowRate = 186;
static const int _lppVolumeStorage = 187;
static const int _lppWater = 188;
static const int _lppGasVolume = 189;
static const int _lppMass = 190;
static const int _lppSignedSpeed = 191;
static const int _lppSignedPower = 192;
static const int _lppSignedCurrent = 193;
/// 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 +95,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 +136,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 +158,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 +171,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 +217,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 +237,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,15 +300,328 @@ 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 _lppGust:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Gust: ${value}m/s');
extraSensorData['gust_$channel'] = value;
break;
case _lppDewPoint:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
extraSensorData['dew_$channel'] = value;
break;
case _lppRain:
final rawValue = reader.readUInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rain: ${value}mm');
extraSensorData['rain_$channel'] = value;
break;
case _lppBinaryBool:
case _lppBinaryPowerSwitch:
case _lppBinaryOpen:
case _lppBinaryBatteryLow:
case _lppBinaryCharging:
case _lppBinaryCarbonMonoxide:
case _lppBinaryCold:
case _lppBinaryConnectivity:
case _lppBinaryDoor:
case _lppBinaryGarageDoor:
case _lppBinaryGas:
case _lppBinaryHeat:
case _lppBinaryLight:
case _lppBinaryLock:
case _lppBinaryMoisture:
case _lppBinaryMotion:
case _lppBinaryMoving:
case _lppBinaryOccupancy:
case _lppBinaryPlug:
case _lppBinaryPresence:
case _lppBinaryProblem:
case _lppBinaryRunning:
case _lppBinarySafety:
case _lppBinarySmoke:
case _lppBinarySound:
case _lppBinaryTamper:
case _lppBinaryVibration:
case _lppBinaryWindow:
final value = reader.readByte();
debugPrint(' Binary state: $value');
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
value;
break;
case _lppButtonEvent:
final value = reader.readByte();
debugPrint(' Button event: $value');
extraSensorData['button_event_$channel'] = value;
break;
case _lppDimmer:
final value = _readInt8(reader);
debugPrint(' Dimmer: $value');
extraSensorData['dimmer_$channel'] = value;
break;
case _lppUv:
final value = reader.readByte() / 10.0;
debugPrint(' UV index: $value');
extraSensorData['uv_$channel'] = value;
break;
case _lppLightLevel:
final value = reader.readByte();
debugPrint(' Light level: $value');
extraSensorData['light_level_$channel'] = value;
break;
case _lppPm25:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM2.5: $value');
extraSensorData['pm25_$channel'] = value;
break;
case _lppPm10:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM10: $value');
extraSensorData['pm10_$channel'] = value;
break;
case _lppCo2:
final value = reader.readUInt16BE().toDouble();
debugPrint(' CO2: $value');
extraSensorData['co2_$channel'] = value;
break;
case _lppTvoc:
final value = reader.readUInt16BE().toDouble();
debugPrint(' TVOC: $value');
extraSensorData['tvoc_$channel'] = value;
break;
case _lppRpm:
final value = reader.readUInt16BE().toDouble();
debugPrint(' RPM: $value');
extraSensorData['rpm_$channel'] = value;
break;
case _lppConductivity:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Conductivity: $value');
extraSensorData['conductivity_$channel'] = value;
break;
case _lppRotation:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rotation: $value');
extraSensorData['rotation_$channel'] = value;
break;
case _lppDuration:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Duration: $value s');
extraSensorData['duration_$channel'] = value;
break;
case _lppAcceleration:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Acceleration: $value');
extraSensorData['acceleration_$channel'] = value;
break;
case _lppGyroRate:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gyro rate: $value');
extraSensorData['gyro_rate_$channel'] = value;
break;
case _lppVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Volume: $value');
extraSensorData['volume_$channel'] = value;
break;
case _lppFlowRate:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Flow rate: $value');
extraSensorData['flow_rate_$channel'] = value;
break;
case _lppVolumeStorage:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Storage volume: $value');
extraSensorData['volume_storage_$channel'] = value;
break;
case _lppWater:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Water: $value');
extraSensorData['water_$channel'] = value;
break;
case _lppGasVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gas volume: $value');
extraSensorData['gas_volume_$channel'] = value;
break;
case _lppMass:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Mass: $value');
extraSensorData['mass_$channel'] = value;
break;
case _lppSignedSpeed:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Signed speed: $value');
extraSensorData['signed_speed_$channel'] = value;
break;
case _lppSignedPower:
final rawValue = _readInt32BE(reader);
final value = rawValue / 100.0;
debugPrint(' Signed power: $value');
extraSensorData['signed_power_$channel'] = value;
break;
case _lppSignedCurrent:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Signed current: $value');
extraSensorData['signed_current_$channel'] = value;
break;
case _lppSwitch:
final value = reader.readByte();
debugPrint(' Switch: $value');
extraSensorData['switch_$channel'] = value;
break;
default: default:
debugPrint( final size = _payloadSizeForType(type);
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', if (size == null || reader.remainingBytesCount < size) {
); debugPrint(
// Unknown type, skip remaining to avoid parsing errors ' ⚠️ Unknown type $type with unsupported size, stopping parse',
reader.skip(reader.remainingBytesCount); );
reader.skip(reader.remainingBytesCount);
break;
}
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
reader.skip(size);
break; break;
} }
} catch (e) { } catch (e) {
@@ -258,6 +677,163 @@ 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 int _readInt32BE(BufferReader reader) {
final value = _readUInt32BE(reader);
if ((value & 0x80000000) != 0) {
return value - 0x100000000;
}
return value;
}
static int _readInt8(BufferReader reader) {
final value = reader.readByte();
if ((value & 0x80) != 0) {
return value - 0x100;
}
return value;
}
static String _sourceChannelKey(String fieldKey) =>
'__source_channel:$fieldKey';
static String _binaryMetricKeyForType(int type) {
switch (type) {
case _lppBinaryBool:
return 'binary_bool';
case _lppBinaryPowerSwitch:
return 'binary_power_switch';
case _lppBinaryOpen:
return 'binary_open';
case _lppBinaryBatteryLow:
return 'binary_battery_low';
case _lppBinaryCharging:
return 'binary_charging';
case _lppBinaryCarbonMonoxide:
return 'binary_carbon_monoxide';
case _lppBinaryCold:
return 'binary_cold';
case _lppBinaryConnectivity:
return 'binary_connectivity';
case _lppBinaryDoor:
return 'binary_door';
case _lppBinaryGarageDoor:
return 'binary_garage_door';
case _lppBinaryGas:
return 'binary_gas';
case _lppBinaryHeat:
return 'binary_heat';
case _lppBinaryLight:
return 'binary_light';
case _lppBinaryLock:
return 'binary_lock';
case _lppBinaryMoisture:
return 'binary_moisture';
case _lppBinaryMotion:
return 'binary_motion';
case _lppBinaryMoving:
return 'binary_moving';
case _lppBinaryOccupancy:
return 'binary_occupancy';
case _lppBinaryPlug:
return 'binary_plug';
case _lppBinaryPresence:
return 'binary_presence';
case _lppBinaryProblem:
return 'binary_problem';
case _lppBinaryRunning:
return 'binary_running';
case _lppBinarySafety:
return 'binary_safety';
case _lppBinarySmoke:
return 'binary_smoke';
case _lppBinarySound:
return 'binary_sound';
case _lppBinaryTamper:
return 'binary_tamper';
case _lppBinaryVibration:
return 'binary_vibration';
case _lppBinaryWindow:
return 'binary_window';
}
return 'binary_state';
}
static int? _payloadSizeForType(int type) {
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
return 1;
}
switch (type) {
case MeshCoreConstants.lppDigitalInput:
case MeshCoreConstants.lppDigitalOutput:
case MeshCoreConstants.lppPresenceSensor:
case MeshCoreConstants.lppHumiditySensor:
case _lppPercentage:
case _lppSwitch:
case _lppButtonEvent:
case _lppDimmer:
case _lppUv:
case _lppLightLevel:
return 1;
case MeshCoreConstants.lppAnalogInput:
case MeshCoreConstants.lppAnalogOutput:
case MeshCoreConstants.lppIlluminanceSensor:
case MeshCoreConstants.lppTemperatureSensor:
case MeshCoreConstants.lppBarometer:
case MeshCoreConstants.lppVoltageSensor:
case _lppCurrent:
case _lppAltitude:
case _lppConcentration:
case _lppPower:
case _lppSpeed:
case _lppDirection:
case _lppGust:
case _lppDewPoint:
case _lppRain:
case _lppPm25:
case _lppPm10:
case _lppCo2:
case _lppTvoc:
case _lppRpm:
case _lppConductivity:
case _lppRotation:
return 2;
case MeshCoreConstants.lppAccelerometer:
case MeshCoreConstants.lppGyrometer:
return 6;
case MeshCoreConstants.lppGps:
return 9;
case _lppGenericSensor:
case _lppFrequency:
case _lppDistance:
case _lppEnergy:
case _lppUnixTime:
case _lppDuration:
case _lppAcceleration:
case _lppGyroRate:
case _lppVolume:
case _lppFlowRate:
case _lppVolumeStorage:
case _lppWater:
case _lppGasVolume:
case _lppMass:
case _lppSignedSpeed:
case _lppSignedPower:
case _lppSignedCurrent:
return 4;
case _lppColour:
return 3;
}
return null;
}
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++) {

View File

@@ -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 {
@@ -167,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(),
}; };
} }
@@ -190,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');
@@ -242,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,

View File

@@ -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;

View File

@@ -329,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,
@@ -337,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,
@@ -465,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,
@@ -562,4 +573,12 @@ class MessageStorageService {
return null; return null;
} }
} }
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -13,14 +14,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);
}
} }

View File

@@ -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();
}
}

View File

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

View File

@@ -1,123 +1,141 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum AppThemeMode { enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
light,
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
class AppTheme { class AppTheme {
static ThemeData _withSharedControls(ThemeData theme) {
final colorScheme = theme.colorScheme;
return theme.copyWith(
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.38);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.onPrimary;
}
return colorScheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.12);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.surfaceContainerHighest;
}),
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.outlineVariant;
}),
),
);
}
// Light theme (Blue) // Light theme (Blue)
static ThemeData get lightTheme { static ThemeData get lightTheme {
return ThemeData( return _withSharedControls(
useMaterial3: true, ThemeData(
colorScheme: ColorScheme.fromSeed( useMaterial3: true,
seedColor: Colors.blue, colorScheme: ColorScheme.fromSeed(
brightness: Brightness.light, seedColor: Colors.blue,
), brightness: Brightness.light,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
inputDecorationTheme: InputDecorationTheme( cardTheme: CardThemeData(
border: OutlineInputBorder( elevation: 2,
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
), ),
filled: true,
), ),
); );
} }
// Dark theme (Blue) // Dark theme (Blue)
static ThemeData get darkTheme { static ThemeData get darkTheme {
return ThemeData( return _withSharedControls(
useMaterial3: true, ThemeData(
colorScheme: ColorScheme.fromSeed( useMaterial3: true,
seedColor: Colors.blue, colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark, seedColor: Colors.blue,
), brightness: Brightness.dark,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
inputDecorationTheme: InputDecorationTheme( cardTheme: CardThemeData(
border: OutlineInputBorder( elevation: 2,
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
), ),
filled: true,
), ),
); );
} }
// SAR Red theme (Emergency/Alert tones) // SAR Red theme (Emergency/Alert tones)
static ThemeData get sarRedTheme { static ThemeData get sarRedTheme {
return ThemeData( return _withSharedControls(
useMaterial3: true, ThemeData(
colorScheme: const ColorScheme.dark( useMaterial3: true,
brightness: Brightness.dark, colorScheme: const ColorScheme.dark(
primary: Color(0xFFFF5252), // Bright red brightness: Brightness.dark,
onPrimary: Color(0xFF000000), primary: Color(0xFFFF5252), // Bright red
primaryContainer: Color(0xFF8B0000), // Dark red onPrimary: Color(0xFF000000),
onPrimaryContainer: Color(0xFFFFCDD2), primaryContainer: Color(0xFF8B0000), // Dark red
secondary: Color(0xFFFF8A80), onPrimaryContainer: Color(0xFFFFCDD2),
onSecondary: Color(0xFF000000), secondary: Color(0xFFFF8A80),
secondaryContainer: Color(0xFFB71C1C), onSecondary: Color(0xFF000000),
onSecondaryContainer: Color(0xFFFFCDD2), secondaryContainer: Color(0xFFB71C1C),
tertiary: Color(0xFFFF6E40), onSecondaryContainer: Color(0xFFFFCDD2),
onTertiary: Color(0xFF000000), tertiary: Color(0xFFFF6E40),
error: Color(0xFFCF6679), onTertiary: Color(0xFF000000),
onError: Color(0xFF000000), error: Color(0xFFCF6679),
surface: Color(0xFF1A0000), // Very dark red-tinted onError: Color(0xFF000000),
onSurface: Color(0xFFFFEBEE), surface: Color(0xFF1A0000), // Very dark red-tinted
surfaceContainerHighest: Color(0xFF2D0000), onSurface: Color(0xFFFFEBEE),
onSurfaceVariant: Color(0xFFFFCDD2), surfaceContainerHighest: Color(0xFF2D0000),
outline: Color(0xFFFF5252), onSurfaceVariant: Color(0xFFFFCDD2),
), outline: Color(0xFFFF5252),
scaffoldBackgroundColor: const Color(0xFF1A0000), ),
appBarTheme: const AppBarTheme( scaffoldBackgroundColor: const Color(0xFF1A0000),
centerTitle: true, appBarTheme: const AppBarTheme(
elevation: 0, centerTitle: true,
backgroundColor: Color(0xFF2D0000), elevation: 0,
foregroundColor: Color(0xFFFFEBEE), backgroundColor: Color(0xFF2D0000),
), foregroundColor: Color(0xFFFFEBEE),
cardTheme: CardThemeData( ),
elevation: 2, cardTheme: CardThemeData(
color: const Color(0xFF2D0000), elevation: 2,
shape: RoundedRectangleBorder( color: const Color(0xFF2D0000),
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
side: const BorderSide( borderRadius: BorderRadius.circular(12),
color: Color(0xFFFF5252), side: const BorderSide(color: Color(0xFFFF5252), width: 1),
width: 1,
), ),
), ),
), inputDecorationTheme: InputDecorationTheme(
inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder(
border: OutlineInputBorder( borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFFF5252)),
borderSide: const BorderSide(color: Color(0xFFFF5252)), ),
filled: true,
fillColor: const Color(0xFF2D0000),
), ),
filled: true, elevatedButtonTheme: ElevatedButtonThemeData(
fillColor: const Color(0xFF2D0000), style: ElevatedButton.styleFrom(
), backgroundColor: const Color(0xFFFF5252),
elevatedButtonTheme: ElevatedButtonThemeData( foregroundColor: const Color(0xFF000000),
style: ElevatedButton.styleFrom( ),
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
), ),
), ),
); );
@@ -125,58 +143,57 @@ class AppTheme {
// SAR Green theme (All Clear/Safe tones) // SAR Green theme (All Clear/Safe tones)
static ThemeData get sarGreenTheme { static ThemeData get sarGreenTheme {
return ThemeData( return _withSharedControls(
useMaterial3: true, ThemeData(
colorScheme: const ColorScheme.dark( useMaterial3: true,
brightness: Brightness.dark, colorScheme: const ColorScheme.dark(
primary: Color(0xFF69F0AE), // Bright green brightness: Brightness.dark,
onPrimary: Color(0xFF000000), primary: Color(0xFF69F0AE), // Bright green
primaryContainer: Color(0xFF00695C), // Dark teal-green onPrimary: Color(0xFF000000),
onPrimaryContainer: Color(0xFFB9F6CA), primaryContainer: Color(0xFF00695C), // Dark teal-green
secondary: Color(0xFF64FFDA), onPrimaryContainer: Color(0xFFB9F6CA),
onSecondary: Color(0xFF000000), secondary: Color(0xFF64FFDA),
secondaryContainer: Color(0xFF004D40), onSecondary: Color(0xFF000000),
onSecondaryContainer: Color(0xFFB9F6CA), secondaryContainer: Color(0xFF004D40),
tertiary: Color(0xFF1DE9B6), onSecondaryContainer: Color(0xFFB9F6CA),
onTertiary: Color(0xFF000000), tertiary: Color(0xFF1DE9B6),
error: Color(0xFFCF6679), onTertiary: Color(0xFF000000),
onError: Color(0xFF000000), error: Color(0xFFCF6679),
surface: Color(0xFF001A12), // Very dark green-tinted onError: Color(0xFF000000),
onSurface: Color(0xFFE8F5E9), surface: Color(0xFF001A12), // Very dark green-tinted
surfaceContainerHighest: Color(0xFF002D1F), onSurface: Color(0xFFE8F5E9),
onSurfaceVariant: Color(0xFFB9F6CA), surfaceContainerHighest: Color(0xFF002D1F),
outline: Color(0xFF69F0AE), onSurfaceVariant: Color(0xFFB9F6CA),
), outline: Color(0xFF69F0AE),
scaffoldBackgroundColor: const Color(0xFF001A12), ),
appBarTheme: const AppBarTheme( scaffoldBackgroundColor: const Color(0xFF001A12),
centerTitle: true, appBarTheme: const AppBarTheme(
elevation: 0, centerTitle: true,
backgroundColor: Color(0xFF002D1F), elevation: 0,
foregroundColor: Color(0xFFE8F5E9), backgroundColor: Color(0xFF002D1F),
), foregroundColor: Color(0xFFE8F5E9),
cardTheme: CardThemeData( ),
elevation: 2, cardTheme: CardThemeData(
color: const Color(0xFF002D1F), elevation: 2,
shape: RoundedRectangleBorder( color: const Color(0xFF002D1F),
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
side: const BorderSide( borderRadius: BorderRadius.circular(12),
color: Color(0xFF69F0AE), side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
width: 1,
), ),
), ),
), inputDecorationTheme: InputDecorationTheme(
inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder(
border: OutlineInputBorder( borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF69F0AE)),
borderSide: const BorderSide(color: Color(0xFF69F0AE)), ),
filled: true,
fillColor: const Color(0xFF002D1F),
), ),
filled: true, elevatedButtonTheme: ElevatedButtonThemeData(
fillColor: const Color(0xFF002D1F), style: ElevatedButton.styleFrom(
), backgroundColor: const Color(0xFF69F0AE),
elevatedButtonTheme: ElevatedButtonThemeData( foregroundColor: const Color(0xFF000000),
style: ElevatedButton.styleFrom( ),
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
), ),
), ),
); );
@@ -184,58 +201,57 @@ class AppTheme {
// SAR Navy Blue theme (Professional/Operations tones) // SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme { static ThemeData get sarNavyBlueTheme {
return ThemeData( return _withSharedControls(
useMaterial3: true, ThemeData(
colorScheme: const ColorScheme.dark( useMaterial3: true,
brightness: Brightness.dark, colorScheme: const ColorScheme.dark(
primary: Color(0xFF5C9FFF), // Bright navy blue brightness: Brightness.dark,
onPrimary: Color(0xFF000000), primary: Color(0xFF5C9FFF), // Bright navy blue
primaryContainer: Color(0xFF003366), // Dark navy onPrimary: Color(0xFF000000),
onPrimaryContainer: Color(0xFFBBDEFF), primaryContainer: Color(0xFF003366), // Dark navy
secondary: Color(0xFF80B3FF), onPrimaryContainer: Color(0xFFBBDEFF),
onSecondary: Color(0xFF000000), secondary: Color(0xFF80B3FF),
secondaryContainer: Color(0xFF002244), onSecondary: Color(0xFF000000),
onSecondaryContainer: Color(0xFFBBDEFF), secondaryContainer: Color(0xFF002244),
tertiary: Color(0xFF4DB8FF), onSecondaryContainer: Color(0xFFBBDEFF),
onTertiary: Color(0xFF000000), tertiary: Color(0xFF4DB8FF),
error: Color(0xFFCF6679), onTertiary: Color(0xFF000000),
onError: Color(0xFF000000), error: Color(0xFFCF6679),
surface: Color(0xFF00111C), // Very dark blue-tinted onError: Color(0xFF000000),
onSurface: Color(0xFFE3F2FD), surface: Color(0xFF00111C), // Very dark blue-tinted
surfaceContainerHighest: Color(0xFF001A2D), onSurface: Color(0xFFE3F2FD),
onSurfaceVariant: Color(0xFFBBDEFF), surfaceContainerHighest: Color(0xFF001A2D),
outline: Color(0xFF5C9FFF), onSurfaceVariant: Color(0xFFBBDEFF),
), outline: Color(0xFF5C9FFF),
scaffoldBackgroundColor: const Color(0xFF00111C), ),
appBarTheme: const AppBarTheme( scaffoldBackgroundColor: const Color(0xFF00111C),
centerTitle: true, appBarTheme: const AppBarTheme(
elevation: 0, centerTitle: true,
backgroundColor: Color(0xFF001A2D), elevation: 0,
foregroundColor: Color(0xFFE3F2FD), backgroundColor: Color(0xFF001A2D),
), foregroundColor: Color(0xFFE3F2FD),
cardTheme: CardThemeData( ),
elevation: 2, cardTheme: CardThemeData(
color: const Color(0xFF001A2D), elevation: 2,
shape: RoundedRectangleBorder( color: const Color(0xFF001A2D),
borderRadius: BorderRadius.circular(12), shape: RoundedRectangleBorder(
side: const BorderSide( borderRadius: BorderRadius.circular(12),
color: Color(0xFF5C9FFF), side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
width: 1,
), ),
), ),
), inputDecorationTheme: InputDecorationTheme(
inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder(
border: OutlineInputBorder( borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)), ),
filled: true,
fillColor: const Color(0xFF001A2D),
), ),
filled: true, elevatedButtonTheme: ElevatedButtonThemeData(
fillColor: const Color(0xFF001A2D), style: ElevatedButton.styleFrom(
), backgroundColor: const Color(0xFF5C9FFF),
elevatedButtonTheme: ElevatedButtonThemeData( foregroundColor: const Color(0xFF000000),
style: ElevatedButton.styleFrom( ),
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
), ),
), ),
); );

View File

@@ -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;

View File

@@ -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;
} }

View File

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

View File

@@ -1,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),
),
);
},
),
);
}),
],
],
);
}
}

View File

@@ -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;

View File

@@ -16,6 +16,7 @@ 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';
@@ -325,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);
@@ -379,6 +385,17 @@ class ContactTile extends StatelessWidget {
_showRoomLoginDialog(context, contact); _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;
await _showSensorPreviewView(context, contact);
},
),
if (canAddToSensors) if (canAddToSensors)
ListTile( ListTile(
leading: Icon( leading: Icon(
@@ -452,6 +469,18 @@ class ContactTile extends StatelessWidget {
); );
} }
Future<void> _showSensorPreviewView(
BuildContext context,
Contact contact,
) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (pageContext) => _SensorPreviewView(contact: contact),
),
);
}
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) {
@@ -980,6 +1009,60 @@ class ContactTile extends StatelessWidget {
} }
} }
class _SensorPreviewView extends StatelessWidget {
final Contact contact;
const _SensorPreviewView({required this.contact});
@override
Widget build(BuildContext context) {
final publicKeyHex = contact.publicKeyHex;
return 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 Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(previewContact.displayName),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
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.',
),
],
),
);
},
);
}
}
class _ContactNameOverrideSheet extends StatefulWidget { class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue; final String initialValue;
final String advertisedName; final String advertisedName;

View File

@@ -131,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,
), ),
@@ -199,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(
@@ -236,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,
), ),
), ),
@@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
: null, : 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(
@@ -279,7 +268,7 @@ 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}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}', '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 trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt) ? const Icon(Icons.sync_alt)
@@ -326,44 +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( final node = resolved.node;
_RouteDisplayEntry.fromResolved( final hashHex = trace.routeHashes[entry.key].toUpperCase();
trace.sender, return _RouteDisplayEntry(
target: const _RouteEntryTarget.sender(), resolved: resolved,
), label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
); );
} }).toList();
entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
);
}),
);
if (trace.recipient.node != null) {
entries.add(
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.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) =>
@@ -527,20 +490,6 @@ class _ContactTraceResult {
_ContactTraceResult cycleEntry(_RouteEntryTarget target) { _ContactTraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) { switch (target.kind) {
case _RouteEntryKind.sender:
return _ContactTraceResult(
sender: sender.cycle(),
recipient: recipient,
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
case _RouteEntryKind.recipient:
return _ContactTraceResult(
sender: sender,
recipient: recipient.cycle(),
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
case _RouteEntryKind.relayNode: case _RouteEntryKind.relayNode:
final updated = matchedRelayNodes.toList(); final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle(); updated[target.index] = updated[target.index].cycle();
@@ -570,26 +519,9 @@ class _RouteDisplayEntry {
}); });
MeshMapNode? get node => resolved.node; MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
target: target,
);
}
} }
enum _RouteEntryKind { sender, recipient, relayNode } enum _RouteEntryKind { relayNode }
class _RouteEntryTarget { class _RouteEntryTarget {
final _RouteEntryKind kind; final _RouteEntryKind kind;
@@ -597,8 +529,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]); const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.relayNode(int index) const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index); : this._(_RouteEntryKind.relayNode, index);
} }

View File

@@ -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',

View File

@@ -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;

View File

@@ -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),
),
], ],
), ),
); );

View File

@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
/// - Grouped messages (expandable recipient list) /// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget { class MessageBubble extends StatefulWidget {
final Message message; final Message message;
final int receivedCopies;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReply; final VoidCallback? onReply;
final bool isHighlighted; final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
this.receivedCopies = 1,
this.onTap, this.onTap,
this.onReply, this.onReply,
this.isHighlighted = false, this.isHighlighted = false,
@@ -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,

View File

@@ -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();
@@ -40,20 +55,27 @@ 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 if (widget.message case final message?) {
: _extractPathFromPacketLogs( final storedPath = messagesProvider
logs: connectionProvider.bleService.packetLogs, .getMessageReceptionDetails(message.id)
message: widget.message, ?.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 senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
? _toPrefixHex(widget.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();
var trace = _buildTraceResult( var trace = _buildTraceResult(
@@ -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;
} }
@@ -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,18 +277,19 @@ 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(
@@ -286,11 +305,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: null, : 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(
@@ -302,7 +317,7 @@ 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}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}', '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 trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt) ? const Icon(Icons.sync_alt)
@@ -356,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);
} }
@@ -365,28 +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,
target: const _RouteEntryTarget.sender(),
),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.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,
@@ -398,54 +396,24 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
target: _RouteEntryTarget.pathNode(entry.key), 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,
target: const _RouteEntryTarget.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( .map(
trace.sender, (entry) => _RouteDisplayEntry.fromResolved(
target: const _RouteEntryTarget.sender(), entry.value,
), target: _RouteEntryTarget.pathNode(entry.key),
...trace.matchedPathNodes
.asMap()
.entries
.where((entry) => entry.value.node != null)
.map(
(entry) => _RouteDisplayEntry.fromResolved(
entry.value,
target: _RouteEntryTarget.pathNode(entry.key),
),
), ),
if (trace.recipient.node != null && )
trace.recipient.node!.publicKey != lastKey) .toList();
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
} }
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;
@@ -521,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,
@@ -549,7 +517,7 @@ 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,
@@ -731,22 +699,6 @@ class _TraceResult {
_TraceResult cycleEntry(_RouteEntryTarget target) { _TraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) { switch (target.kind) {
case _RouteEntryKind.sender:
return _TraceResult(
mode: mode,
sender: sender.cycle(),
recipient: recipient,
pathHashes: pathHashes,
matchedPathNodes: matchedPathNodes,
);
case _RouteEntryKind.recipient:
return _TraceResult(
mode: mode,
sender: sender,
recipient: recipient.cycle(),
pathHashes: pathHashes,
matchedPathNodes: matchedPathNodes,
);
case _RouteEntryKind.pathNode: case _RouteEntryKind.pathNode:
final updated = matchedPathNodes.toList(); final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle(); updated[target.index] = updated[target.index].cycle();
@@ -796,7 +748,7 @@ class _RouteDisplayEntry {
} }
} }
enum _RouteEntryKind { sender, recipient, pathNode } enum _RouteEntryKind { pathNode }
class _RouteEntryTarget { class _RouteEntryTarget {
final _RouteEntryKind kind; final _RouteEntryKind kind;
@@ -804,8 +756,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]); const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.pathNode(int index) const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index); : this._(_RouteEntryKind.pathNode, index);
} }

View File

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

View File

@@ -2,13 +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 '../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;
@@ -84,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

File diff suppressed because it is too large Load Diff

View File

@@ -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: "461f010" ref: f600789fac7f743b1c7db8aa24e441405413f669
resolved-ref: "461f010a2f1fcd343357c152228891e295d391be" 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:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0313.2+29 version: 2026.0315.1+31
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: "461f010" 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

View File

@@ -137,11 +137,12 @@ void main() {
}); });
test( test(
'retains last valid gps for any contact when telemetry gps is invalid or missing', 'clears telemetry gps on refresh when gps is invalid or missing but keeps fallback display location',
() { () {
final contactTypes = <ContactType>[ final contactTypes = <ContactType>[
ContactType.chat, ContactType.chat,
ContactType.repeater, ContactType.repeater,
ContactType.sensor,
ContactType.room, ContactType.room,
ContactType.channel, ContactType.channel,
]; ];
@@ -164,23 +165,24 @@ void main() {
); );
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps); scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps);
// No GPS frame should keep previous valid GPS. // No GPS frame should clear telemetry GPS but keep the last known
// contact location as display fallback.
final batteryOnly = CayenneLppParser.createBatteryData(3.8); final batteryOnly = CayenneLppParser.createBatteryData(3.8);
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly); scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly);
var updated = scopedProvider.findContactByKey(scopedKey)!; var updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(
updated.telemetry!.gpsLocation!.latitude, updated.displayLocation!.latitude,
closeTo(45.1234, 0.0001), closeTo(45.1234, 0.0001),
); );
expect( expect(
updated.telemetry!.gpsLocation!.longitude, updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001), closeTo(13.8765, 0.0001),
); );
// Invalid 0,0 GPS frame should also keep previous valid GPS. // Invalid 0,0 GPS frame should behave the same way.
final invalidGps = CayenneLppParser.createGpsData( final invalidGps = CayenneLppParser.createGpsData(
latitude: 0.0, latitude: 0.0,
longitude: 0.0, longitude: 0.0,
@@ -189,13 +191,13 @@ void main() {
updated = scopedProvider.findContactByKey(scopedKey)!; updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(
updated.telemetry!.gpsLocation!.latitude, updated.displayLocation!.latitude,
closeTo(45.1234, 0.0001), closeTo(45.1234, 0.0001),
); );
expect( expect(
updated.telemetry!.gpsLocation!.longitude, updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001), closeTo(13.8765, 0.0001),
); );
} }
@@ -335,7 +337,7 @@ void main() {
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
}); });
test('retains prior telemetry fields across sparse telemetry updates', () { test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () {
final fullTelemetry = ContactTelemetry( final fullTelemetry = ContactTelemetry(
gpsLocation: const LatLng(46.0569, 14.5058), gpsLocation: const LatLng(46.0569, 14.5058),
batteryPercentage: 54.0, batteryPercentage: 54.0,
@@ -359,13 +361,70 @@ void main() {
final updated = provider.findContactByKey(publicKey)!; final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058)); expect(updated.telemetry!.gpsLocation, isNull);
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull); expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull); expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5)); expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0)); expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2)); expect(updated.telemetry!.pressure, equals(1011.2));
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0)); expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:battery', 0),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:voltage', 0),
);
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
});
test('replaces old source-channel mappings when a metric moves channels', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: null,
batteryPercentage: null,
batteryMilliVolts: null,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: null,
pressure: null,
extraSensorData: const {
'__source_channel:temperature': 2,
'temperature_2': 21.5,
'humidity_4': 66.0,
},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
23.5,
channel: 3,
);
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
expect(
updated.telemetry!.extraSensorData,
containsPair('__source_channel:temperature', 3),
);
expect(
updated.telemetry!.extraSensorData,
containsPair('temperature_3', closeTo(23.5, 0.1)),
);
expect(
updated.telemetry!.extraSensorData,
isNot(contains('temperature_2')),
);
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4')));
}); });
test('builds message snapshot from latest valid telemetry', () { test('builds message snapshot from latest valid telemetry', () {

View File

@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/models/path_selection.dart'; import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart'; import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -347,6 +348,88 @@ void main() {
expect(provider.messages.single.id, equals('c-prefix')); expect(provider.messages.single.id, equals('c-prefix'));
}); });
test('duplicate incoming message increments received copy count', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
provider.addMessage(
Message(
id: 'copy-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000600,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'copy-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000601,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(1));
expect(
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
equals(2),
);
});
test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
provider.addMessages([
Message(
id: 'dup-a',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000705,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
Message(
id: 'dup-b',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000700,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
]);
final display = provider.buildDisplayMessages(
provider.getRecentMessages(),
);
expect(display, hasLength(1));
expect(display.single.message.id, equals('dup-a'));
expect(display.single.occurrenceCount, equals(2));
});
test('missing ACK schedules a delayed retransmission', () { test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) { fakeAsync((async) {
final provider = MessagesProvider(); final provider = MessagesProvider();

View File

@@ -0,0 +1,273 @@
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,
);
}
Contact buildTelemetrySensorContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x55;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Weather Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
temperature: 11.8,
humidity: 51,
pressure: 921.9,
timestamp: DateTime.now(),
extraSensorData: const {
'__source_channel:temperature': 3,
'__source_channel:humidity': 2,
'__source_channel:pressure': 2,
'illuminance_2': 19150.0,
'temperature_2': 1.99,
'voltage_2': 5.2,
'switch_2': 0,
'speed_2': 2.8,
'speed_3': 3.7,
'uv_2': 1.0,
},
),
);
}
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, 1440);
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
});
test(
'unsupported auto refresh minutes normalize to nearest option',
() async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1);
expect(provider.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);
});
test(
'addSensor includes available extra telemetry fields by default',
() async {
SharedPreferences.setMockInitialValues({});
final contact = buildTelemetrySensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
expect(
provider.visibleFieldsFor(contact.publicKeyHex),
containsAll(<String>{
'temperature',
'humidity',
'pressure',
'extra:illuminance_2',
'extra:temperature_2',
'extra:voltage_2',
'extra:switch_2',
'extra:speed_2',
'extra:speed_3',
'extra:uv_2',
}),
);
},
);
}

View File

@@ -57,6 +57,25 @@ void main() {
); );
} }
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( Future<void> pumpContactsTab(
WidgetTester tester, { WidgetTester tester, {
List<Contact> contacts = const [], List<Contact> contacts = const [],
@@ -192,4 +211,14 @@ void main() {
expect(find.text('Others'), findsNothing); expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget); 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);
});
} }

View File

@@ -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.text('Response'), findsWidgets); 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', (
@@ -174,6 +180,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('No packets for this filter'), findsNothing); expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget); expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
}); });
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.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/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.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/channels_provider.dart';
@@ -16,10 +17,34 @@ import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.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';
import 'dart:typed_data';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); 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(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
@@ -104,4 +129,83 @@ void main() {
connectionProvider.dispose(); connectionProvider.dispose();
channelsProvider.dispose(); channelsProvider.dispose();
}); });
testWidgets('shows mention overlay above composer and inserts selection', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
]);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.byType(TextField).first);
await tester.enterText(find.byType(TextField).first, '@t');
await tester.pumpAndSettle();
expect(find.text('Tim'), findsOneWidget);
expect(find.text('Slane'), findsNothing);
await tester.tap(find.text('Tim'));
await tester.pumpAndSettle();
expect(find.text('@[Tim] '), findsOneWidget);
expect(find.text('Tim'), findsNothing);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
} }

View File

@@ -0,0 +1,97 @@
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 action opens full customization view', (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.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.text('Customize WX Station'), findsOneWidget);
expect(find.text('Live preview'), findsOneWidget);
expect(find.text('Refresh schedule'), findsOneWidget);
expect(
find.byKey(
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
}

View File

@@ -360,6 +360,134 @@ 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 lppGust = 137;
const lppDewPoint = 138;
const lppRain = 139;
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
15, lppGust, 0x01, 0x72, // 3.70 m/s
16, lppDewPoint, 0x00, 0x14, // 2.0 C
17, lppRain, 0x00, 0x7B, // 12.3 mm
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!['gust_15'], closeTo(3.7, 0.001));
expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001));
expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001));
expect(decoded.extraSensorData!['switch_14'], equals(1));
});
test('new MeshCore custom telemetry types decode without truncation', () {
const lppBinaryMoisture = 157;
const lppButtonEvent = 171;
const lppDimmer = 172;
const lppUv = 173;
const lppPm25 = 175;
const lppConductivity = 180;
const lppDuration = 182;
const lppSignedSpeed = 191;
const lppSignedPower = 192;
const lppSignedCurrent = 193;
final payload = Uint8List.fromList([
2, lppBinaryMoisture, 0x01,
3, lppButtonEvent, 0x04,
4, lppDimmer, 0xFB, // -5
5, lppUv, 0x0A, // 1.0
6, lppPm25, 0x00, 0x0C, // 12
7, lppConductivity, 0x01, 0xF4, // 500
8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s
9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s
10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W
11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A
12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData!['binary_moisture_2'], equals(1));
expect(decoded.extraSensorData!['button_event_3'], equals(4));
expect(decoded.extraSensorData!['dimmer_4'], equals(-5));
expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001));
expect(decoded.extraSensorData!['pm25_6'], equals(12.0));
expect(decoded.extraSensorData!['conductivity_7'], equals(500.0));
expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001));
expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001));
expect(
decoded.extraSensorData!['signed_power_10'],
closeTo(-10.0, 0.001),
);
expect(
decoded.extraSensorData!['signed_current_11'],
closeTo(-1.0, 0.001),
);
expect(decoded.temperature, closeTo(11.8, 0.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);

View File

@@ -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,6 +96,7 @@ 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),

View 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);
});
}

View File

@@ -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);
});
} }

View File

@@ -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(
@@ -104,4 +112,82 @@ void main() {
expect(find.text(contact.publicKeyShort), findsNothing); expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), 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.byIcon(Icons.close), findsOneWidget);
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));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
});
} }

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
void main() {
testWidgets('renders expanded auto refresh intervals and selects value', (
tester,
) async {
var selected = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorAutoRefreshOptions(
selectedMinutes: selected,
onSelected: (minutes) {
selected = minutes;
},
),
),
),
);
expect(find.text('Off'), findsOneWidget);
expect(find.text('5m'), findsOneWidget);
expect(find.text('15m'), findsOneWidget);
expect(find.text('30m'), findsOneWidget);
expect(find.text('60m'), findsOneWidget);
expect(find.text('6h'), findsOneWidget);
expect(find.text('12h'), findsOneWidget);
expect(find.text('24h'), findsOneWidget);
await tester.tap(find.text('24h'));
await tester.pump();
expect(selected, 1440);
});
}

View File

@@ -0,0 +1,91 @@
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',
previewCardData: SensorMetricCardData(
fieldKey: 'extra:illuminance_2',
icon: Icons.light_mode_outlined,
label: 'Illuminance',
value: '500 lx',
accent: Color(0xFFC17B1D),
channel: 2,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Show on sensor card'), findsOneWidget);
expect(find.text('500 lx'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
testWidgets('omits duplicate channel chip for channel 1', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'battery',
label: 'Battery',
defaultLabel: 'Battery',
channel: 1,
valuePreview: '84%',
previewCardData: SensorMetricCardData(
fieldKey: 'battery',
icon: Icons.battery_5_bar,
label: 'Battery',
value: '84%',
accent: Color(0xFF4B8E2F),
channel: 1,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Rename'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_battery')),
findsNothing,
);
expect(find.text('Channel 1'), findsNothing);
});
}

View File

@@ -0,0 +1,190 @@
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));
});
testWidgets('renders MeshCore custom weather metrics', (tester) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x46;
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(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
}),
),
),
),
);
expect(find.text('Wind gust'), findsOneWidget);
expect(find.text('Dew point'), findsOneWidget);
expect(find.text('Rain'), findsOneWidget);
expect(find.text('3.7 m/s'), findsOneWidget);
expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), findsOneWidget);
});
}