Compare commits

...

27 Commits

Author SHA1 Message Date
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 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 11266 additions and 3701 deletions

13
CHANGELOG.md Normal file
View File

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

View File

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

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>115</string>
<string>117</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<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 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 classname="fastlane.lanes" name="2: build_app" time="95.271262">
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="91.795184">
<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 classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
</testcase>

View File

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

View File

@@ -14,9 +14,6 @@ enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default)
ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp,
}
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.tcp:
return 'Direct (WiFi)';
}
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
}
@@ -66,6 +59,11 @@ class DeviceInfo {
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -113,6 +111,11 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -239,6 +242,11 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -278,6 +286,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,

View File

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

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:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart' show BufferReader;
import 'package:shared_preferences/shared_preferences.dart';
import 'connection_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/path_history_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/notification_service.dart';
import '../models/contact.dart';
import '../models/message.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
class AppProvider with ChangeNotifier {
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
static bool isDeletedChannelInfo(
int channelIdx,
@@ -88,6 +147,7 @@ class AppProvider with ChangeNotifier {
LocationTrackingService();
final PacketCaptureStorageService packetCaptureStorageService =
PacketCaptureStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
@@ -117,8 +177,6 @@ class AppProvider with ChangeNotifier {
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
@@ -148,13 +206,19 @@ class AppProvider with ChangeNotifier {
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
final Map<String, Map<String, MediaSwarmAvailability>>
_pendingMediaSwarmResponses = {};
final Map<String, DateTime> _recentRepeaterStatusRequests = {};
final Map<String, DateTime> _recentRepeaterOwnerInfoRequests = {};
final Map<int, _PendingRepeaterOwnerRequest> _pendingRepeaterOwnerRequests =
{};
bool _fastLocationScreenActive = false;
Timer? _packetCaptureFlushTimer;
Timer? _lowBatteryCheckTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
AppProvider({
required this.connectionProvider,
@@ -181,10 +245,10 @@ class AppProvider with ChangeNotifier {
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale();
_loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence();
_startLowBatteryWatcher();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true;
}
@@ -197,6 +261,58 @@ class AppProvider with ChangeNotifier {
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) {
final prefix = log.rawData.length <= 12
? log.rawData
@@ -249,17 +365,15 @@ class AppProvider with ChangeNotifier {
/// Sync drawings from messages on app startup (before BLE connection)
Future<void> _syncDrawingsOnStartup() async {
// Wait for MessagesProvider to finish initializing
// DrawingProvider loads around the same time
// Wait for both MessagesProvider and DrawingProvider to finish initializing
int attempts = 0;
while (!messagesProvider.isInitialized && attempts < 20) {
while ((!messagesProvider.isInitialized ||
!drawingProvider.isInitialized) &&
attempts < 40) {
await Future.delayed(const Duration(milliseconds: 50));
attempts++;
}
// Give DrawingProvider a moment to finish loading too
await Future.delayed(const Duration(milliseconds: 100));
_restoreSessionMetadataFromMessages();
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 {
try {
_autoRouteRotationEnabled =
@@ -805,16 +895,49 @@ class AppProvider with ChangeNotifier {
);
};
// 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
contactsProvider.addOrUpdateContact(
contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
devicePublicKey: devicePublicKey,
);
unawaited(_pathHistoryService.recordLearnedPath(contact));
// Broadcast to SSE clients if server is running
connectionProvider.broadcastContactToSseClients(contact);
};
// When all contacts are received
@@ -828,11 +951,6 @@ class AppProvider with ChangeNotifier {
unawaited(_pathHistoryService.recordLearnedPath(contact));
}
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
@@ -975,9 +1093,10 @@ class AppProvider with ChangeNotifier {
if (AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: connectionProvider.deviceInfo.publicKey,
ownName:
connectionProvider.deviceInfo.deviceName ??
connectionProvider.deviceInfo.selfName,
ownName: _preferredSelfDisplayName(
deviceName: connectionProvider.deviceInfo.deviceName,
selfName: connectionProvider.deviceInfo.selfName,
),
)) {
debugPrint('⏭️ [AppProvider] Ignoring self replay: ${message.id}');
return;
@@ -1057,9 +1176,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
// Broadcast drawing message to SSE clients if server is running
connectionProvider.broadcastMessageToSseClients(updatedMessage);
} else {
debugPrint('⚠️ [AppProvider] Failed to parse drawing message');
}
@@ -1100,7 +1216,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
}
@@ -1134,7 +1249,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot,
receptionDetailsSnapshot: receptionDetailsSnapshot,
);
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
return;
}
@@ -1159,9 +1273,6 @@ class AppProvider with ChangeNotifier {
contactLocationSnapshot: contactLocationSnapshot,
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.
@@ -1180,6 +1291,9 @@ class AppProvider with ChangeNotifier {
// Used by newer firmware versions for telemetry and other binary data
// BOTH callbacks (0x8B and 0x8C) must be handled for device compatibility
connectionProvider.onBinaryResponse = (publicKeyPrefix, tag, responseData) {
if (_handlePendingRepeaterOwnerResponse(tag, responseData)) {
return;
}
debugPrint(
'📊 [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 0x49 'I' = image packet.
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);
if (fastGpsPacket != null) {
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
@@ -1398,6 +1552,31 @@ class AppProvider with ChangeNotifier {
_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
connectionProvider.onPathUpdated = (publicKey) {
debugPrint(
@@ -1434,22 +1613,30 @@ class AppProvider with ChangeNotifier {
}
});
} else {
if (_autoAddDiscoveredContacts) {
debugPrint(' Unknown contact - auto-add enabled, fetching details');
Future.delayed(const Duration(milliseconds: 100), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(publicKey);
}
});
} else {
contactsProvider.addPendingAdvert(
publicKey,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
debugPrint(
' Unknown contact - added to pending adverts list and waiting for details',
final isNewPendingAdvert = contactsProvider.addPendingAdvert(
publicKey,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
debugPrint(
' Unknown contact - added to pending adverts list and waiting for manual resolution',
);
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 (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(
List<int> pathBytes, {
required int preferredHashSize,
@@ -1897,12 +2390,9 @@ class AppProvider with ChangeNotifier {
// Get battery and storage information
await connectionProvider.getBatteryAndStorage();
// Load contacts
// Load contacts (waits for device to finish sending all contacts)
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.
final channelsToSync = connectionProvider.deviceInfo.maxChannels;
debugPrint(
@@ -3079,9 +3569,72 @@ class AppProvider with ChangeNotifier {
_imageMissingRetryAttempts.clear();
_voiceSessionSenderKey6.clear();
_imageSessionSenderKey6.clear();
_lowBatteryNotifiedNodeIds.clear();
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
Map<String, dynamic> get statistics {
return {
@@ -3099,6 +3652,7 @@ class AppProvider with ChangeNotifier {
@override
void dispose() {
_packetCaptureFlushTimer?.cancel();
_lowBatteryCheckTimer?.cancel();
unawaited(_flushPacketCaptureLogs());
// Remove connection state listener
connectionProvider.removeListener(_handleConnectionStateChange);

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.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/device_info.dart';
import '../models/room_login_state.dart';
import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart';
import 'helpers/message_delivery_tracker.dart';
@@ -55,10 +55,22 @@ class ScannedDevice {
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)
class ConnectionProvider with ChangeNotifier {
static const int _controlTypeNodeDiscoverReq = 0x80;
final MeshCoreBleService _bleService = MeshCoreBleService();
final SseServerService _sseServer = SseServerService();
MeshCoreTcpService? _tcpService;
/// Expose BLE service for background location tracking
@@ -74,10 +86,6 @@ class ConnectionProvider with ChangeNotifier {
ConnectionMode _connectionMode = ConnectionMode.ble;
ConnectionMode get connectionMode => _connectionMode;
/// SSE server configuration
SseServerConfig _sseServerConfig = const SseServerConfig();
SseServerConfig get sseServerConfig => _sseServerConfig;
/// TCP host last connected to (for display / reconnection info)
String? _tcpHost;
String? get tcpHost => _tcpHost;
@@ -130,6 +138,9 @@ class ConnectionProvider with ChangeNotifier {
// Completer to wait for response before sending next sync request
Completer<bool>? _syncResponseCompleter;
// Completer to wait for contacts sync to finish
Completer<void>? _contactsSyncCompleter;
// Lightweight guards for other commands that can be double-tapped
bool _isLoginInProgress = false;
DateTime? _lastLoginRequestedAt;
@@ -142,6 +153,13 @@ class ConnectionProvider with ChangeNotifier {
bool _isAdvertInProgress = false;
DateTime? _lastAdvertRequestedAt;
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
final RoomLoginManager _roomLoginManager = RoomLoginManager();
@@ -159,6 +177,7 @@ class ConnectionProvider with ChangeNotifier {
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(Contact, ContactReceiveSource)? onContactReceivedDetailed;
Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
@@ -186,6 +205,8 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
@@ -238,6 +259,10 @@ class ConnectionProvider with ChangeNotifier {
};
service.onError = (error, {int? errorCode}) {
if (errorCode == 2 && _suppressNextPreviewNotFoundError) {
_suppressNextPreviewNotFoundError = false;
return;
}
debugPrint('⚠️ [Provider] Error received: $error');
_error = error;
if (_deviceInfo.connectionState != ConnectionState.connected) {
@@ -249,9 +274,21 @@ class ConnectionProvider with ChangeNotifier {
};
service.onContactNotFound = (contactPublicKey) async {
debugPrint('🔧 [Provider] Contact not found - initiating auto-recovery');
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
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -261,7 +298,6 @@ class ConnectionProvider with ChangeNotifier {
try {
await _activeService.addOrUpdateContact(pendingOp.contact!);
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingDirectMessage(
@@ -286,11 +322,18 @@ class ConnectionProvider with ChangeNotifier {
service.onContactReceived = (contact) {
debugPrint('📥 [Provider] Contact received: "${contact.advName}"');
final source = _classifyContactReceiveSource(contact.publicKey);
onContactReceivedDetailed?.call(contact, source);
onContactReceived?.call(contact);
};
service.onContactsComplete = (contacts) {
debugPrint('📥 [Provider] Contacts sync complete: ${contacts.length}');
_isContactsSyncInProgress = false;
if (_contactsSyncCompleter != null &&
!_contactsSyncCompleter!.isCompleted) {
_contactsSyncCompleter!.complete();
}
onContactsComplete?.call(contacts);
};
@@ -402,6 +445,8 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
service.onDeviceInfoReceived = (deviceInfo) {
debugPrint('📥 [Provider] DeviceInfo received');
@@ -419,11 +464,6 @@ class ConnectionProvider with ChangeNotifier {
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
);
notifyListeners();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
};
service.onSelfInfoReceived = (selfInfo) {
@@ -443,11 +483,6 @@ class ConnectionProvider with ChangeNotifier {
selfName: selfInfo['selfName'] as String?,
);
notifyListeners();
if (_sseServer.isRunning) {
_sseServer.setDeviceName(
_deviceInfo.deviceName ?? _deviceInfo.selfName,
);
}
};
service.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
@@ -475,6 +510,17 @@ class ConnectionProvider with ChangeNotifier {
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 = () {
_txActivity = true;
notifyListeners();
@@ -583,6 +629,9 @@ class ConnectionProvider with ChangeNotifier {
await stopScan();
}
// Ensure we route commands to BLE, not a stale TCP service.
_connectionMode = ConnectionMode.ble;
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty
@@ -591,6 +640,8 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting,
);
_error = null;
_supportsAutoaddConfig = null;
_resetSyncState();
debugPrint('✅ [Provider] Device info updated to connecting state');
notifyListeners();
@@ -620,6 +671,7 @@ class ConnectionProvider with ChangeNotifier {
connectionState: ConnectionState.connecting,
);
_error = null;
_supportsAutoaddConfig = null;
notifyListeners();
// Create fresh TCP service and wire its callbacks
@@ -648,6 +700,8 @@ class ConnectionProvider with ChangeNotifier {
}
_tcpHost = null;
_connectionMode = ConnectionMode.ble;
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
@@ -670,6 +724,8 @@ class ConnectionProvider with ChangeNotifier {
await _bleService.disconnect();
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
@@ -678,6 +734,24 @@ class ConnectionProvider with ChangeNotifier {
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
/// This is useful when the user wants to manually disconnect during reconnection
void cancelReconnection() {
@@ -719,7 +793,10 @@ class ConnectionProvider with ChangeNotifier {
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 {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -728,10 +805,24 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_isContactsSyncInProgress = true;
_contactsSyncCompleter = Completer<void>();
await _activeService.getContacts();
await _contactsSyncCompleter!.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
debugPrint(
'⚠️ [Provider] Contacts sync timeout - proceeding without full list',
);
},
);
} catch (e) {
_isContactsSyncInProgress = false;
_error = 'Failed to get contacts: $e';
notifyListeners();
} finally {
_isContactsSyncInProgress = false;
_contactsSyncCompleter = null;
}
}
@@ -749,18 +840,99 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_error = null;
_markSingleContactRequested(
publicKey,
source: ContactReceiveSource.requestedSingle,
);
await _activeService.getContactByKey(publicKey);
} 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';
debugPrint(
'⚠️ [Provider] Failed to get contact by key, falling back to full contact sync',
);
// Fallback to full contact sync if command not supported
_isContactsSyncInProgress = true;
await _activeService.getContacts();
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
Future<void> syncChannels({int? maxChannels}) async {
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 Future.delayed(const Duration(milliseconds: 100));
// Check again after query
if (getChannelInfo != null) {
@@ -1137,11 +1308,9 @@ class ConnectionProvider with ChangeNotifier {
// behavior, then reconcile with an on-device refresh below.
onChannelInfoReceived?.call(channelIdx, '', Uint8List(16), null);
// Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100));
// Refresh channels to update UI
// The empty channel will trigger removal via onChannelInfoReceived callback
// Refresh channel from device to reconcile UI state.
// BLE commands are processed in order by firmware, so the
// getChannel response reflects the completed deletion.
await _activeService.getChannel(channelIdx);
} catch (e) {
_pendingDeletedChannelIndices.remove(channelIdx);
@@ -1181,6 +1350,7 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_error = null;
await _activeService.addOrUpdateContact(contact);
} catch (e) {
_error = 'Failed to add/update contact: $e';
@@ -1188,6 +1358,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
///
/// Returns true if the message was successfully sent to the BLE service.
@@ -1230,12 +1416,11 @@ class ConnectionProvider with ChangeNotifier {
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
);
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
// (User action shouldn't be blocked forever)
await Future.delayed(const Duration(milliseconds: 500));
// Wait for a slot to free up (delivery/timeout), max 500ms
await _messageDeliveryTracker.waitForSlot();
if (_messageDeliveryTracker.shouldRateLimit) {
debugPrint(
@@ -1311,30 +1496,14 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt,
);
if (messageId != null) {
Future.delayed(const Duration(milliseconds: 350), () {
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
// Clear pending operation — any ERR_CODE_NOT_FOUND has already been
// handled synchronously by onContactNotFound before sendTextMessage returns.
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':');
// Use a small delay to allow error response to arrive before clearing
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
_pendingSendOperations.remove(operationId);
}
return true;
@@ -1394,10 +1563,6 @@ class ConnectionProvider with ChangeNotifier {
);
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
// This will trigger the callback to mark the message as "sent"
debugPrint(' Calling onMessageSent callback...');
@@ -1636,6 +1801,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
Future<void> getDeviceTime() async {
if (!_activeService.isConnected) {
@@ -1866,12 +2065,96 @@ class ConnectionProvider with ChangeNotifier {
advertLocationPolicy: advertLocationPolicy,
multiAcks: multiAcks,
);
_deviceInfo = _deviceInfo.copyWith(
telemetryModes: telemetryModes,
advertLocPolicy: advertLocationPolicy,
multiAcks: multiAcks,
);
notifyListeners();
} catch (e) {
_error = 'Failed to set other params: $e';
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)
Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
@@ -1884,14 +2167,31 @@ class ConnectionProvider with ChangeNotifier {
try {
// The device query command triggers a SelfInfo response
await _activeService.refreshDeviceInfo();
// Also request allowed repeat frequencies (firmware v9+, no-op on older firmware)
await _activeService.getAllowedRepeatFreq();
if (_supportsAutoaddConfig != false) {
unawaited(getAutoaddConfig());
}
try {
await _activeService.getAllowedRepeatFreq();
} catch (_) {
// Older firmware may not expose repeat frequency ranges.
}
} catch (e) {
_error = 'Failed to refresh device info: $e';
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
///
/// Queries the companion radio for:
@@ -2175,6 +2475,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
///
/// Clears the learned path to a contact, forcing the next message to use
@@ -2235,7 +2557,7 @@ class ConnectionProvider with ChangeNotifier {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
throw Exception(_error);
}
try {
@@ -2243,6 +2565,7 @@ class ConnectionProvider with ChangeNotifier {
} catch (e) {
_error = 'Failed to remove contact: $e';
notifyListeners();
rethrow;
}
}
@@ -2262,117 +2585,13 @@ class ConnectionProvider with ChangeNotifier {
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
void dispose() {
_rxActivityTimer?.cancel();
_txActivityTimer?.cancel();
_stopAckCleanupTimer();
_bleService.dispose();
_tcpService?.dispose();
_sseServer.stopServer();
super.dispose();
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
@@ -12,14 +13,40 @@ import '../utils/key_comparison.dart';
class PendingAdvert {
final Uint8List publicKey;
final DateTime receivedAt;
final String? advName;
final int? typeValue;
final int? flags;
final int? lastAdvert;
final int? advLat;
final int? advLon;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
final int? rxRssiDbm;
final int? rxSnrRaw;
final int? repeaterBatteryMv;
final int? repeaterQueueLen;
final int? repeaterLastRssi;
final int? repeaterLastSnrRaw;
final int? repeaterUptimeSecs;
const PendingAdvert({
required this.publicKey,
required this.receivedAt,
this.advName,
this.typeValue,
this.flags,
this.lastAdvert,
this.advLat,
this.advLon,
this.signedEncodedPathLen,
this.paddedPathBytes,
this.rxRssiDbm,
this.rxSnrRaw,
this.repeaterBatteryMv,
this.repeaterQueueLen,
this.repeaterLastRssi,
this.repeaterLastSnrRaw,
this.repeaterUptimeSecs,
});
String get publicKeyHex =>
@@ -33,16 +60,55 @@ class PendingAdvert {
PendingAdvert copyWith({
Uint8List? publicKey,
DateTime? receivedAt,
String? advName,
int? typeValue,
int? flags,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
int? repeaterBatteryMv,
int? repeaterQueueLen,
int? repeaterLastRssi,
int? repeaterLastSnrRaw,
int? repeaterUptimeSecs,
}) {
return PendingAdvert(
publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt,
advName: advName ?? this.advName,
typeValue: typeValue ?? this.typeValue,
flags: flags ?? this.flags,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
);
}
double? get repeaterBatteryPercent {
if (repeaterBatteryMv == null) return null;
final voltage = repeaterBatteryMv! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
double? get repeaterLastSnr =>
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
}
class _RetainedRoute {
@@ -64,6 +130,10 @@ class ContactsProvider with ChangeNotifier {
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
// Add default public channel on initialization
ContactsProvider() {
@@ -83,6 +153,7 @@ class ContactsProvider with ChangeNotifier {
);
final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -99,6 +170,7 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups
..clear()
..addAll(storedGroups);
_restorePendingAdverts(storedPendingAdverts);
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
@@ -131,6 +203,7 @@ class ContactsProvider with ChangeNotifier {
excludePublicKey: devicePublicKey,
);
final storedGroups = await _storageService.loadContactGroups();
final storedPendingAdverts = await _storageService.loadPendingAdverts();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -147,6 +220,10 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups
..clear()
..addAll(storedGroups);
_restorePendingAdverts(
storedPendingAdverts,
devicePublicKey: devicePublicKey,
);
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
@@ -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 {
_persistRequested = true;
if (_isPersisting) return;
_isPersisting = true;
try {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveContacts(_contactsForStorage());
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
} finally {
_isPersisting = false;
}
}
Future<void> _persistPendingAdverts() async {
_persistPendingAdvertsRequested = true;
if (_isPersistingPendingAdverts) return;
_isPersistingPendingAdverts = true;
try {
while (_persistPendingAdvertsRequested) {
_persistPendingAdvertsRequested = false;
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
);
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
} finally {
_isPersistingPendingAdverts = false;
}
}
@@ -226,6 +322,28 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return _pendingAdverts[keyHex];
}
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
final advert = pendingAdvertByKey(publicKey);
if (advert == null) {
return false;
}
final hasName = advert.advName?.trim().isNotEmpty ?? false;
final hasType = advert.typeValue != null && advert.typeValue != 0;
final hasLocation =
advert.advLat != null &&
advert.advLon != null &&
(advert.advLat != 0 || advert.advLon != 0);
return !(hasName && hasType && hasLocation);
}
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
@@ -332,6 +450,9 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get sensorContacts =>
contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen);
List<Contact> get rooms =>
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}',
);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
}
@@ -471,6 +593,7 @@ class ContactsProvider with ChangeNotifier {
);
}
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -503,7 +626,6 @@ class ContactsProvider with ChangeNotifier {
if (existingContact == null) {
var newContact = incomingContact.copyWith(
isNew: true,
nameOverride: existingContact?.nameOverride,
telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
@@ -908,7 +1030,7 @@ class ContactsProvider with ChangeNotifier {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
for (final contact in _contacts.values) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
@@ -1063,39 +1185,132 @@ class ContactsProvider with ChangeNotifier {
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
/// Excludes only self key; known contacts still keep a discovery entry.
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return;
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex);
return;
}
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
if (existing != null) {
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
_persistPendingAdverts();
notifyListeners();
return false;
} else {
_pendingAdverts[keyHex] = PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
);
_persistPendingAdverts();
notifyListeners();
return true;
}
}
bool addOrUpdatePendingAdvertContact(
Contact contact, {
Uint8List? devicePublicKey,
}) {
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = contact.publicKeyHex;
final route = ContactRouteCodec.fromContact(contact);
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
final updated =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(contact.publicKey),
receivedAt: now,
))
.copyWith(
receivedAt: now,
advName: contact.advName.trim().isEmpty ? null : contact.advName,
typeValue: contact.type.value,
flags: contact.flags,
lastAdvert: contact.lastAdvert,
advLat: contact.advLat,
advLon: contact.advLon,
signedEncodedPathLen:
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: route?.paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(route!.paddedPathBytes),
);
_pendingAdverts[keyHex] = updated;
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
bool addOrUpdatePendingAdvertMetadata({
required Uint8List publicKey,
required int typeValue,
Uint8List? devicePublicKey,
int? flags,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
DateTime? receivedAt,
}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final existing = _pendingAdverts[keyHex];
final nextReceivedAt = receivedAt ?? DateTime.now();
_pendingAdverts[keyHex] =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: nextReceivedAt,
))
.copyWith(
receivedAt: nextReceivedAt,
typeValue: typeValue,
advName: advName?.trim().isNotEmpty == true
? advName!.trim()
: existing?.advName,
flags: flags ?? existing?.flags,
lastAdvert: lastAdvert ?? existing?.lastAdvert,
advLat: advLat ?? existing?.advLat,
advLon: advLon ?? existing?.advLon,
signedEncodedPathLen:
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(paddedPathBytes),
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
);
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
for (final contact in _contacts.values) {
if (contact.advName == name) return contact;
}
return null;
}
/// Get contacts with low battery
@@ -1144,7 +1359,9 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_ensurePublicChannelExists();
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -1167,6 +1384,7 @@ class ContactsProvider with ChangeNotifier {
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -1175,11 +1393,145 @@ class ContactsProvider with ChangeNotifier {
return await _storageService.getStorageStats();
}
Future<void> persistNow() async {
await _storageService.saveContacts(_contactsForStorage());
}
Future<void> clearPendingAdverts() async {
if (_pendingAdverts.isEmpty) {
return;
}
_pendingAdverts.clear();
await _storageService.clearPendingAdverts();
notifyListeners();
}
void updatePendingAdvertStatusByPrefix(
Uint8List publicKeyPrefix, {
int? batteryMv,
int? queueLen,
int? lastRssi,
int? lastSnrRaw,
int? uptimeSecs,
}) {
final prefixHex = publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final match = _pendingAdverts.entries.where(
(entry) => entry.key.startsWith(prefixHex),
);
if (match.isEmpty) {
return;
}
final key = match.first.key;
final existing = _pendingAdverts[key]!;
_pendingAdverts[key] = existing.copyWith(
repeaterBatteryMv: batteryMv,
repeaterQueueLen: queueLen,
repeaterLastRssi: lastRssi,
repeaterLastSnrRaw: lastSnrRaw,
repeaterUptimeSecs: uptimeSecs,
);
_persistPendingAdverts();
notifyListeners();
}
List<Contact> _contactsForStorage() {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
return _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
}
void _restorePendingAdverts(
List<Map<String, dynamic>> storedPendingAdverts, {
Uint8List? devicePublicKey,
}) {
_pendingAdverts.clear();
for (final json in storedPendingAdverts) {
final advert = _pendingAdvertFromJson(json);
if (advert == null) continue;
if (devicePublicKey != null &&
advert.publicKey.matches(devicePublicKey)) {
continue;
}
if (_contacts.containsKey(advert.publicKeyHex)) {
continue;
}
_pendingAdverts[advert.publicKeyHex] = advert;
}
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
return {
'publicKey': base64Encode(advert.publicKey),
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
'advName': advert.advName,
'typeValue': advert.typeValue,
'flags': advert.flags,
'lastAdvert': advert.lastAdvert,
'advLat': advert.advLat,
'advLon': advert.advLon,
'signedEncodedPathLen': advert.signedEncodedPathLen,
'paddedPathBytes': advert.paddedPathBytes == null
? null
: base64Encode(advert.paddedPathBytes!),
'rxRssiDbm': advert.rxRssiDbm,
'rxSnrRaw': advert.rxSnrRaw,
'repeaterBatteryMv': advert.repeaterBatteryMv,
'repeaterQueueLen': advert.repeaterQueueLen,
'repeaterLastRssi': advert.repeaterLastRssi,
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
};
}
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
try {
return PendingAdvert(
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int,
),
advName: json['advName'] as String?,
typeValue: json['typeValue'] as int?,
flags: json['flags'] as int?,
lastAdvert: json['lastAdvert'] as int?,
advLat: json['advLat'] as int?,
advLon: json['advLon'] as int?,
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
paddedPathBytes: json['paddedPathBytes'] == null
? null
: Uint8List.fromList(
base64Decode(json['paddedPathBytes'] as String),
),
rxRssiDbm: json['rxRssiDbm'] as int?,
rxSnrRaw: json['rxSnrRaw'] as int?,
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
repeaterQueueLen: json['repeaterQueueLen'] as int?,
repeaterLastRssi: json['repeaterLastRssi'] as int?,
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
);
} catch (e) {
debugPrint(
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
);
return null;
}
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'sensor': sensorContacts.length,
'room': rooms.length,
'total': contacts.length,
};

View File

@@ -22,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -53,6 +54,7 @@ class DrawingProvider with ChangeNotifier {
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none;
bool get isInitialized => _isInitialized;
LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance;
@@ -61,6 +63,7 @@ class DrawingProvider with ChangeNotifier {
Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings();
_isInitialized = true;
}
/// Set drawing mode

View File

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

View File

@@ -18,6 +18,8 @@ import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
@@ -28,6 +30,8 @@ class MessagesProvider with ChangeNotifier {
final MessageStorageService _storageService = MessageStorageService();
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
@@ -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
if (message.text.startsWith('S:')) {
debugPrint(
@@ -580,8 +593,21 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
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();
return; // Skip duplicate
@@ -692,7 +718,7 @@ class MessagesProvider with ChangeNotifier {
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
if (!existing.isSentMessage && !message.isSentMessage) {
return true;
return withinChannelRepeatWindow;
}
if (!withinChannelRepeatWindow) {
@@ -715,7 +741,8 @@ class MessagesProvider with ChangeNotifier {
return false;
}
return true;
// System messages and other types: never deduplicate by scope alone.
return false;
}
/// Add multiple messages
@@ -725,10 +752,42 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) {
// Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
var enhancedMessage = SarMessageParser.enhanceMessage(message);
// Check if it's a drawing message (D:...) and not already marked
if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) &&
!enhancedMessage.isDrawing) {
final drawing = DrawingMessageParser.parseDrawingMessage(
enhancedMessage.text,
senderName: enhancedMessage.senderName,
messageId: enhancedMessage.id,
);
enhancedMessage = enhancedMessage.copyWith(
isDrawing: true,
drawingId: drawing?.id,
);
}
// Check if it's a voice message and not already marked
if (!enhancedMessage.isVoice) {
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
if (envelope != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: envelope.sessionId,
);
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
);
duplicateCount++;
continue; // Skip duplicate
}
@@ -973,18 +1032,31 @@ class MessagesProvider with ChangeNotifier {
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
}
/// Persist messages to storage (async, non-blocking)
/// Persist messages to storage (async, non-blocking, coalescing).
///
/// Multiple rapid calls are coalesced into a single write to avoid
/// overlapping serialization and redundant SharedPreferences writes.
Future<void> _persistMessages() async {
_persistRequested = true;
if (_isPersisting) {
return; // A write is in flight; it will pick up our changes.
}
_isPersisting = true;
try {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
}
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} finally {
_isPersisting = false;
}
}
@@ -1028,6 +1100,7 @@ class MessagesProvider with ChangeNotifier {
for (final messageId in messageIdsToRemove) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
_clearChannelSendWarning(messageId);
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
@@ -1062,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
return sorted.take(count).toList();
}
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
final entries = <DisplayMessageEntry>[];
for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere(
(entry) =>
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
);
if (existingIndex == -1) {
entries.add((message: message, occurrenceCount: occurrenceCount));
continue;
}
final existingEntry = entries[existingIndex];
entries[existingIndex] = (
message: existingEntry.message,
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
);
}
return entries;
}
int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
/// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration);
@@ -1245,11 +1347,7 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages
void clearMessages() {
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_cancelAllTimers();
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
@@ -1257,6 +1355,14 @@ class MessagesProvider with ChangeNotifier {
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
@@ -1272,11 +1378,7 @@ class MessagesProvider with ChangeNotifier {
/// Clear all data
void clearAll() {
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_cancelAllTimers();
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
@@ -1284,6 +1386,14 @@ class MessagesProvider with ChangeNotifier {
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
@@ -1964,7 +2074,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Message index in list: $index');
if (index != -1) {
final updatedMessage = message.copyWith(
final updatedMessage = _messages[index].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(),
@@ -2323,6 +2433,7 @@ class MessagesProvider with ChangeNotifier {
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx,
pathLen: message.pathLen,
pathBytes: message.pathBytes,
textType: message.textType,
senderTimestamp: message.senderTimestamp,
text: message.text,
@@ -2410,7 +2521,19 @@ class MessagesProvider with ChangeNotifier {
@override
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) {
timer.cancel();
}
@@ -2420,14 +2543,6 @@ class MessagesProvider with ChangeNotifier {
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
void _rememberCompletedAck(int ackCode) {

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
@@ -15,6 +16,19 @@ class SensorsProvider with ChangeNotifier {
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
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>{
'voltage',
'battery',
@@ -23,6 +37,14 @@ class SensorsProvider with ChangeNotifier {
'pressure',
'gps',
};
static const List<String> _defaultMetricOrder = <String>[
'voltage',
'battery',
'temperature',
'humidity',
'pressure',
'gps',
];
final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates =
@@ -32,8 +54,15 @@ class SensorsProvider with ChangeNotifier {
<String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor =
<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 _isRefreshingAll = false;
bool _isRunningAutoRefreshTick = false;
SensorsProvider() {
unawaited(_loadWatchedSensors());
@@ -52,11 +81,17 @@ class SensorsProvider with ChangeNotifier {
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
final storedSpansJson = prefs.getString(_fieldSpanKey);
final storedLabelsJson = prefs.getString(_metricLabelKey);
final storedOrderJson = prefs.getString(_metricOrderKey);
final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey);
_watchedSensorKeys
..clear()
..addAll(stored);
_visibleFieldsBySensor.clear();
_fieldSpansBySensor.clear();
_metricLabelsBySensor.clear();
_metricOrderBySensor.clear();
_autoRefreshMinutesBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
@@ -72,12 +107,49 @@ class SensorsProvider with ChangeNotifier {
.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) {
_visibleFieldsBySensor.putIfAbsent(
key,
() => Set<String>.from(_defaultVisibleFields),
);
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
_metricOrderBySensor.putIfAbsent(
key,
() => List<String>.from(_defaultMetricOrder),
);
}
} catch (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(
_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) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey);
@@ -131,6 +249,104 @@ class SensorsProvider with ChangeNotifier {
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(
String publicKeyHex,
String fieldKey,
@@ -140,8 +356,17 @@ class SensorsProvider with ChangeNotifier {
publicKeyHex,
() => Set<String>.from(_defaultVisibleFields),
);
final metricOrder = _metricOrderBySensor.putIfAbsent(
publicKeyHex,
() => List<String>.from(_defaultMetricOrder),
);
var shouldPersistOrder = false;
if (visible) {
visibleFields.add(fieldKey);
if (!metricOrder.contains(fieldKey)) {
metricOrder.add(fieldKey);
shouldPersistOrder = true;
}
} else {
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
return;
@@ -149,6 +374,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners();
}
@@ -166,11 +394,62 @@ class SensorsProvider with ChangeNotifier {
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) =>
_watchedSensorKeys.contains(publicKeyHex);
Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater) {
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return;
}
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
@@ -178,25 +457,69 @@ class SensorsProvider with ChangeNotifier {
}
_watchedSensorKeys.add(contact.publicKeyHex);
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
await _persistWatchedSensors();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
_defaultVisibleFields,
);
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
contact.publicKeyHex,
initialVisibleFields,
);
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
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 {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex);
_metricLabelsBySensor.remove(publicKeyHex);
_metricOrderBySensor.remove(publicKeyHex);
_autoRefreshMinutesBySensor.remove(publicKeyHex);
_lastRefreshAttemptAt.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
await _persistAutoRefreshMinutes();
notifyListeners();
}
@@ -204,6 +527,7 @@ class SensorsProvider with ChangeNotifier {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
...contactsProvider.sensorContacts,
];
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
@@ -239,7 +563,14 @@ class SensorsProvider with ChangeNotifier {
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? requestedAt,
}) async {
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
return;
}
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact;
for (final entry in contactsProvider.contacts) {
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}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];

View File

@@ -0,0 +1,259 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key});
@override
State<AddContactScreen> createState() => _AddContactScreenState();
}
class _AddContactScreenState extends State<AddContactScreen> {
final TextEditingController _advertController = TextEditingController();
bool _isImporting = false;
bool _importSucceeded = false;
String? _validationError;
@override
void initState() {
super.initState();
_loadClipboardIfPresent();
}
@override
void dispose() {
_advertController.dispose();
super.dispose();
}
Future<void> _loadClipboardIfPresent() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted || text == null || text.isEmpty) {
return;
}
if (_normalizeAdvertText(text) == null) {
return;
}
_advertController.text = text;
}
String? _normalizeAdvertText(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
var normalized = trimmed;
if (normalized.startsWith('meshcore://')) {
normalized = normalized.substring('meshcore://'.length);
}
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) {
return null;
}
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
if (!isHex || normalized.length.isOdd) {
return null;
}
return normalized.toLowerCase();
}
Uint8List _hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
}
return Uint8List.fromList(bytes);
}
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
return;
}
setState(() {
_advertController.text = text.trim();
_importSucceeded = false;
_validationError = null;
});
}
Future<void> _importContact() async {
final normalized = _normalizeAdvertText(_advertController.text);
if (normalized == null) {
setState(() {
_validationError =
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
});
return;
}
final advertBytes = _hexToBytes(normalized);
if (advertBytes.length < 98) {
setState(() {
_validationError =
'Advert is too short. Expected exported contact data.';
});
return;
}
setState(() {
_isImporting = true;
_importSucceeded = false;
_validationError = null;
});
final connectionProvider = context.read<ConnectionProvider>();
await connectionProvider.importContactAdvert(advertBytes);
final importError = connectionProvider.error;
if (importError == null) {
await connectionProvider.getContacts();
}
if (!mounted) {
return;
}
setState(() {
_isImporting = false;
});
if (importError != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(importError)));
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Contact imported')));
setState(() {
_importSucceeded = true;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final normalized = _normalizeAdvertText(_advertController.text);
return Scaffold(
appBar: AppBar(title: const Text('Add Contact')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Import an exported contact advert, like meshcore-open.',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Paste a `meshcore://...` link or raw hex advert from the clipboard.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
controller: _advertController,
minLines: 4,
maxLines: 8,
onChanged: (_) {
if (_validationError != null || _importSucceeded) {
setState(() {
_importSucceeded = false;
_validationError = null;
});
}
},
decoration: InputDecoration(
labelText: 'Contact advert',
hintText: 'meshcore://...',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
errorText: _validationError,
),
),
const SizedBox(height: 12),
if (normalized != null)
Text(
'Advert size: ${normalized.length ~/ 2} bytes',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _isImporting ? null : _pasteFromClipboard,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Paste'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: (_isImporting || _importSucceeded)
? null
: _importContact,
icon: _isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _importSucceeded
? const Icon(Icons.check_circle_outline)
: const Icon(Icons.person_add_alt_1_outlined),
label: Text(
_isImporting
? 'Importing...'
: _importSucceeded
? 'Added'
: 'Add Contact',
),
),
),
],
),
if (_importSucceeded) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.check_circle,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Contact added. Paste or edit another advert to import again.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
],
),
);
}
}

View File

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

View File

@@ -1,8 +1,15 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../models/channel.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/validation_service.dart';
import '../l10n/app_localizations.dart';
@@ -14,6 +21,11 @@ class DeviceConfigScreen extends StatefulWidget {
}
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
static const int _bulkDeleteBatchSize = 8;
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
static const int _autoAddFilterModeFlag = 1;
static const List<_RadioPreset> _radioPresets = [
_RadioPreset(
id: 'australia',
@@ -166,16 +178,30 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _lonController;
late TextEditingController _freqController;
late TextEditingController _txPowerController;
late final ConnectionProvider _connectionProvider;
bool _telemetryEnabled = false;
bool _repeatEnabled = false;
bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true;
bool _autoAddRoomServersEnabled = true;
bool _autoAddSensorsEnabled = true;
bool _overwriteOldestAutoAddEnabled = false;
bool _showCustomRadioSettings = false;
bool _isSavingPublicInfo = false;
bool _isSavingRadioSettings = false;
bool _isSavingAutoDiscoverySettings = false;
bool _isClearingContacts = false;
bool _isClearingChannels = false;
bool _publicInfoSaved = false;
bool _radioSettingsSaved = false;
bool _autoDiscoverySettingsSaved = false;
bool _autoDiscoverySettingsDirty = false;
String? _lastAutoDiscoverySignature;
String? _publicInfoError;
String? _radioSettingsError;
String? _autoDiscoverySettingsError;
String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8;
@@ -197,7 +223,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
final deviceInfo = _connectionProvider.deviceInfo;
_nameController = TextEditingController(
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
@@ -252,6 +280,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false;
_syncAutoDiscoveryState(deviceInfo);
_lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo);
// Fetch allowed repeat frequencies on open if device supports repeat mode
if (deviceInfo.clientRepeat != null &&
@@ -262,12 +292,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConnectionProvider>().getBatteryAndStorage();
_connectionProvider.getBatteryAndStorage();
unawaited(_connectionProvider.getAutoaddConfig());
});
}
@override
void dispose() {
_connectionProvider.removeListener(_handleConnectionProviderChanged);
_nameController.dispose();
_latController.dispose();
_lonController.dispose();
@@ -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 {
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService();
setState(() {
@@ -425,7 +531,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 1,
);
@@ -436,7 +542,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00;
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 0,
);
@@ -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 {
try {
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
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
@@ -781,6 +1144,164 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: 'Auto discovery',
subtitle:
'Control how the radio auto-adds discovered nodes to its contacts table.',
icon: Icons.person_search_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingHighlightCard(
icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1
: Icons.person_add_disabled,
title: 'Enable automatic adding',
description:
'Turn this off to keep discoveries manual-only on the radio.',
accentColor: _autoAddDiscoveredContactsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddDiscoveredContactsEnabled,
onChanged: (value) {
setState(() {
_autoAddDiscoveredContactsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
},
),
),
const SizedBox(height: 18),
_SettingHighlightCard(
icon: Icons.person_outline_rounded,
title: 'Auto-add users',
description:
'Automatically store discovered user/chat nodes.',
accentColor: _autoAddUsersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddUsersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddUsersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.router_outlined,
title: 'Auto-add repeaters',
description:
'Automatically store discovered repeater nodes.',
accentColor: _autoAddRepeatersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRepeatersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRepeatersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.meeting_room_outlined,
title: 'Auto-add room servers',
description:
'Automatically store discovered room/server nodes.',
accentColor: _autoAddRoomServersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRoomServersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRoomServersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.sensors_outlined,
title: 'Auto-add sensors',
description:
'Automatically store discovered sensor nodes.',
accentColor: _autoAddSensorsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddSensorsEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddSensorsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.history_toggle_off_rounded,
title: 'Overwrite oldest when full',
description:
'Allow the radio to replace the oldest contact when storage is full.',
accentColor: _overwriteOldestAutoAddEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _overwriteOldestAutoAddEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_overwriteOldestAutoAddEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 18),
if (_autoDiscoverySettingsError != null) ...[
Text(
_autoDiscoverySettingsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 10),
],
SizedBox(
width: double.infinity,
child: _SaveActionButton(
onPressed: _isSavingAutoDiscoverySettings
? null
: _saveAutoDiscoverySettings,
isSaving: _isSavingAutoDiscoverySettings,
isSaved: _autoDiscoverySettingsSaved,
label: 'Save discovery settings',
),
),
],
),
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo,
subtitle: 'Choose the name and location this device shares.',
@@ -1230,10 +1751,60 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllContacts,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingContacts
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.people_alt_outlined),
label: const Text('Clear all contacts'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllChannels,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingChannels
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.forum_outlined),
label: const Text('Clear all channels'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _confirmFactoryReset,
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmFactoryReset,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
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 'messages_tab.dart';
import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
@@ -768,7 +802,11 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
case _HomeTab.sensors:
return const SensorsTab();
return SensorsTab(
isActive:
_currentTab == _HomeTab.sensors &&
_lifecycleState == AppLifecycleState.resumed,
);
case _HomeTab.map:
return MapTab(
onFullscreenChanged: (isFullscreen) {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
@@ -11,6 +12,8 @@ import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
T? _maybeProvider<T>(BuildContext context) {
@@ -134,11 +137,12 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[],
);
final packetTypes = unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final packetTypes =
unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final filteredEntries = snapshot.visibleEntries;
return Scaffold(
@@ -202,7 +206,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
Icon(
Icons.radar_rounded,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.45),
color: theme.colorScheme.primary.withValues(
alpha: 0.45,
),
),
const SizedBox(height: 16),
const Text(
@@ -218,7 +224,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
@@ -488,7 +496,9 @@ class _FilterChip extends StatelessWidget {
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
@@ -641,103 +651,103 @@ class _LiveTrafficCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final routePreview = _resolvedRoutePreview(context, entry);
final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.payloadLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetDetails.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (signalMetric != null) ...[
const SizedBox(width: 12),
CompactSignalIndicator(metric: signalMetric),
] else
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
),
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 10),
_PacketInfoLine(
text:
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PacketMetaChip(
label: '${log.rawData.length} bytes',
onTap: () => _showPacketBytesSheet(context, log.rawData),
),
if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null)
_PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
),
if (entry.hopCount != null)
_PacketMetaChip(label: '${entry.hopCount} hops'),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
if (packetDetails.pathLine != null)
_PacketInfoLine(text: packetDetails.pathLine!),
if (packetDetails.pathHashLine != null)
_PacketInfoLine(text: packetDetails.pathHashLine!),
if (packetDetails.endpointLine != null)
_PacketInfoLine(text: packetDetails.endpointLine!),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
_PacketInfoLine(text: 'Origin: $originDistance'),
],
),
const SizedBox(height: 10),
Text(
routePreview,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -748,36 +758,11 @@ class _LiveTrafficCard extends StatelessWidget {
return '${diff.inMinutes}m ago';
}
static String _resolvedRoutePreview(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return entry.routePreview;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
if (contactsProvider == null && connectionProvider == null) {
return entry.routePreview;
}
final ownLatLng = _ownLatLng(connectionProvider);
final resolvedLabels = route.hopHashes.map((hashHex) {
final resolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
return _compactNodeLabel(resolved);
}).toList();
return resolvedLabels.join(' -> ');
static String _formatClock(DateTime timestamp) {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
static String? _originDistanceLabel(
@@ -856,11 +841,16 @@ class _LiveTrafficCard extends StatelessWidget {
final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
hexLines.add(
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
);
}
final ascii = data
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
.map(
(byte) =>
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
)
.join();
return showModalBottomSheet<void>(
@@ -886,10 +876,7 @@ class _LiveTrafficCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text(
'Hex',
style: Theme.of(context).textTheme.titleSmall,
),
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -909,10 +896,7 @@ class _LiveTrafficCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text(
'ASCII',
style: Theme.of(context).textTheme.titleSmall,
),
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -940,17 +924,187 @@ class _LiveTrafficCard extends StatelessWidget {
);
}
static String _compactNodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return node.label;
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
if (node.matchCount > 0) {
return node.label;
}
return node.hexLabel;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {
final String title;
final String packetHashHex;
final String? pathLine;
final String? pathHashLine;
final String? endpointLine;
const _LiveTrafficPacketDetails({
required this.title,
required this.packetHashHex,
required this.pathLine,
required this.pathHashLine,
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
);
final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST',
0x01 => 'FLOOD RESPONSE',
0x02 => 'FLOOD TEXT',
0x03 => 'FLOOD ACK',
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
};
final hopHashes = route?.hopHashes ?? const <String>[];
final pathLine = hopHashes.isEmpty
? null
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
final pathHashLine = route == null
? null
: 'Path Hashes: ${route.hashSize}-byte per hop';
return _LiveTrafficPacketDetails(
title: title,
packetHashHex: _packetHash(entry.log.rawData),
pathLine: pathLine,
pathHashLine: pathHashLine,
endpointLine: parsedPayload?.endpointLine,
);
}
static String _packetHash(List<int> bytes) {
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 {
final double latitude;
final double longitude;
@@ -958,44 +1112,6 @@ class _GeoPoint {
const _GeoPoint(this.latitude, this.longitude);
}
class _PacketMetaChip extends StatelessWidget {
final String label;
final bool emphasized;
final VoidCallback? onTap;
const _PacketMetaChip({
required this.label,
this.emphasized = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = emphasized ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
);
}
}
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push(
context,

View File

@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
TextEditingValue _lastComposerValue = const TextEditingValue();
bool _isMentionPickerOpen = false;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state
String _destinationType =
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void initState() {
super.initState();
_lastComposerValue = _textController.value;
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
@@ -161,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId);
final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list
@@ -195,25 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
}
void _handleComposerChanged() {
final previousValue = _lastComposerValue;
final currentValue = _textController.value;
_lastComposerValue = currentValue;
_updateCharacterCount();
if (_suppressMentionTrigger || _isMentionPickerOpen) {
if (_suppressMentionTrigger) {
return;
}
final mentionTriggerRange = _getMentionTriggerRange(
previousValue: previousValue,
currentValue: currentValue,
);
if (mentionTriggerRange == null) {
return;
}
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() {
@@ -222,43 +216,112 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
TextRange? _getMentionTriggerRange({
required TextEditingValue previousValue,
required TextEditingValue currentValue,
}) {
if (!previousValue.selection.isValid ||
!currentValue.selection.isValid ||
!previousValue.selection.isCollapsed ||
!currentValue.selection.isCollapsed) {
void _handleFocusChanged() {
if (!_focusNode.hasFocus) {
_clearMentionSuggestions();
return;
}
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null;
}
final previousOffset = previousValue.selection.baseOffset;
final currentOffset = currentValue.selection.baseOffset;
if (previousOffset < 0 || currentOffset < 0) {
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
if (currentValue.text.length != previousValue.text.length + 1 ||
currentOffset != previousOffset + 1) {
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
if (currentValue.text.substring(0, previousOffset) !=
previousValue.text.substring(0, previousOffset)) {
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
if (currentValue.text.substring(currentOffset) !=
previousValue.text.substring(previousOffset)) {
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
if (currentValue.text[previousOffset] != '@') {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
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 =>
@@ -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
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
setState(() {
@@ -489,15 +502,24 @@ class _MessagesTabState extends State<MessagesTab> {
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_lastComposerValue = _textController.value;
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit();
}
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String? senderDisplayName;
String destinationType;
Contact? recipient;
@@ -522,28 +544,48 @@ class _MessagesTabState extends State<MessagesTab> {
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
final roomRecipient = message.recipientPublicKey == null
? null
: contactsProvider.findContactByKey(message.recipientPublicKey!);
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
if (roomRecipient?.isRoom == true) {
destinationType = MessageDestinationPreferences.destinationTypeRoom;
recipient = roomRecipient;
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus();
}
@@ -2002,7 +2044,9 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
_syncChannelAutoReadTimer(messagesProvider);
final messages = _getFilteredMessages(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -2040,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,

View File

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

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/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart';
import '../widgets/update_dialog.dart';
import 'sar_template_management_screen.dart';
import 'welcome_wizard_screen.dart';
@@ -77,6 +77,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _discoveryNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
@@ -99,6 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
}
@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 {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
@@ -187,8 +206,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_rotateMapWithHeading =
prefs.getBool('map_rotate_with_heading') ?? false;
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? 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'),
_buildSettingsCard([
Consumer<AppProvider>(
@@ -1047,19 +1147,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'),
_buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile(
leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'),
@@ -1224,9 +1311,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'),
subtitle: const Text(
'Display SAR markers on the main map',
),
subtitle: const Text('Display SAR markers on the main map'),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
@@ -1468,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
SwitchListTile(
@@ -1795,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: Theme.of(context).textTheme.bodySmall,
),
),
Switch.adaptive(
Switch(
value: _showCurrentImagePreview,
onChanged: (value) {
setState(() => _showCurrentImagePreview = value);

View File

@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
/// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices
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
static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...');
@@ -25,7 +95,8 @@ class CayenneLppParser {
int fieldCount = 0;
while (reader.hasRemaining) {
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
if (fieldCount > 0 &&
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
debugPrint(
' Detected zero-padded telemetry tail, stopping parse at position '
'${data.length - reader.remainingBytesCount}',
@@ -65,14 +136,16 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V');
extraSensorData['analog_input_$channel'] = value;
// If this is a battery reading
if (channel == 0 || channel == 1) {
if (_isBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else {
extraSensorData['analog_input_$channel'] = value;
}
break;
@@ -85,7 +158,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppIlluminanceSensor:
final value = reader.readUInt16BE();
final value = reader.readUInt16BE().toDouble();
debugPrint(' Illuminance: $value lux');
extraSensorData['illuminance_$channel'] = value;
break;
@@ -98,18 +171,36 @@ class CayenneLppParser {
case MeshCoreConstants.lppTemperatureSensor:
final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0;
final value = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue');
debugPrint(
' Temperature: ${temperature.toStringAsFixed(1)}°C',
);
debugPrint(' Temperature: ${value.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;
case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte();
humidity = rawValue / 2.0;
final value = rawValue / 2.0;
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;
case MeshCoreConstants.lppAccelerometer:
@@ -126,9 +217,19 @@ class CayenneLppParser {
case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0;
final value = rawValue / 10.0;
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;
case MeshCoreConstants.lppVoltageSensor:
@@ -136,12 +237,17 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V');
// Treat voltage sensor as battery reading
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
if (_isBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else {
extraSensorData['voltage_$channel'] = value;
}
break;
case MeshCoreConstants.lppGyrometer:
@@ -194,15 +300,328 @@ class CayenneLppParser {
}
gpsLocation = LatLng(lat, lon);
extraSensorData[_sourceChannelKey('gps')] = channel;
extraSensorData['altitude_$channel'] = alt;
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:
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
);
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount);
final size = _payloadSizeForType(type);
if (size == null || reader.remainingBytesCount < size) {
debugPrint(
' ⚠️ Unknown type $type with unsupported size, stopping parse',
);
reader.skip(reader.remainingBytesCount);
break;
}
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
reader.skip(size);
break;
}
} catch (e) {
@@ -258,6 +677,163 @@ class CayenneLppParser {
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) {
final start = data.length - remainingBytes;
for (int i = start; i < data.length; i++) {

View File

@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async {
@@ -126,6 +128,47 @@ class ContactStorageService {
}
}
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
}
}
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey);
if (jsonString == null || jsonString.isEmpty) {
return const [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.whereType<Map<String, dynamic>>().toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
return const [];
}
}
Future<void> clearPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey);
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -167,6 +210,15 @@ class ContactStorageService {
'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!)
: null,
'advertHistory': contact.advertHistory
.map(
(point) => {
'lat': point.location.latitude,
'lon': point.location.longitude,
'tsMillis': point.timestamp.millisecondsSinceEpoch,
},
)
.toList(),
};
}
@@ -190,6 +242,7 @@ class ContactStorageService {
telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null,
advertHistory: _advertHistoryFromJson(json['advertHistory']),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
@@ -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) {
return {
'id': group.id,

View File

@@ -42,89 +42,100 @@ class MapMarkerService {
double mapRotation = 0,
Position? userPosition,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return contacts
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(contact) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: getLocationAgeColor(contact),
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
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),
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(contact) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
],
),
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,
),
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,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
],
),
),
),
),
);
}).whereType<Marker>().toList();
);
})
.whereType<Marker>()
.toList();
}
/// Generate markers for SAR events.
@@ -157,7 +168,10 @@ class MapMarkerService {
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: getSarMarkerColor(marker.type),
borderRadius: BorderRadius.circular(3),
@@ -188,7 +202,7 @@ class MapMarkerService {
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
@@ -196,13 +210,17 @@ class MapMarkerService {
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.displayName, // Uses notes if available, otherwise type.displayName
marker
.displayName, // Uses notes if available, otherwise type.displayName
style: const TextStyle(
color: Colors.white,
fontSize: 9,
@@ -269,7 +287,8 @@ class MapMarkerService {
final dLat = (lat2 - lat1) * 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(lat2 * pi / 180) *
sin(dLon / 2) *
@@ -299,8 +318,8 @@ class MapMarkerService {
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
@@ -368,6 +387,8 @@ class MapMarkerService {
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
@@ -389,6 +410,8 @@ class MapMarkerService {
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
@@ -466,7 +489,8 @@ class MapMarkerService {
}
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;

View File

@@ -329,6 +329,8 @@ class MessageStorageService {
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -337,6 +339,7 @@ class MessageStorageService {
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
@@ -465,6 +468,14 @@ class MessageStorageService {
: null,
channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int,
pathBytes: json['pathBytes'] is List
? Uint8List.fromList(
(json['pathBytes'] as List<dynamic>)
.whereType<num>()
.map((b) => b.toInt())
.toList(),
)
: null,
textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String,
@@ -562,4 +573,12 @@ class MessageStorageService {
return null;
}
}
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart';
@@ -13,14 +14,28 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
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 _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
static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels
static const String _urgentChannelId = 'sar_urgent';
@@ -38,12 +53,32 @@ class NotificationService {
static const String _updateChannelDescription =
'Notifications for available app updates';
static const String _batteryChannelId = 'battery_alerts';
static const String _batteryChannelName = 'Battery Alerts';
static const String _batteryChannelDescription =
'Notifications when a device or contact battery is low';
static const String _discoveryChannelId = 'discovery_alerts';
static const String _discoveryChannelName = 'Discovery Alerts';
static const String _discoveryChannelDescription =
'Notifications when new contacts are discovered';
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
bool get discoveryNotificationsEnabled => _discoveryNotificationsEnabled;
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
bool get muteForegroundNotifications => _muteForegroundNotifications;
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
/// Initialize notification service
Future<void> initialize() async {
if (_isInitialized) return;
try {
debugPrint('📬 [NotificationService] Initializing...');
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
_lifecycleState =
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
// Initialize timezone data
tz.initializeTimeZones();
@@ -72,8 +107,15 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse,
);
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions
await _requestPermissions();
await _loadPreferences();
// Create notification channels (Android)
await _createNotificationChannels();
@@ -132,6 +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
Future<void> _createNotificationChannels() async {
try {
@@ -177,9 +267,31 @@ class NotificationService {
showBadge: true,
);
const batteryChannel = AndroidNotificationChannel(
_batteryChannelId,
_batteryChannelName,
description: _batteryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
const discoveryChannel = AndroidNotificationChannel(
_discoveryChannelId,
_discoveryChannelName,
description: _discoveryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
@@ -201,6 +313,12 @@ class NotificationService {
}
}
String? consumeLaunchPayload() {
final payload = _launchPayload;
_launchPayload = null;
return payload;
}
/// Show urgent notification for SAR marker
Future<void> showSarNotification({
required SarMarkerType type,
@@ -222,6 +340,16 @@ class NotificationService {
);
return;
}
if (!_sarNotificationsEnabled) {
debugPrint(' [NotificationService] SAR notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping SAR notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -394,6 +522,16 @@ class NotificationService {
);
return;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping message notification',
);
return;
}
try {
// Generate unique notification ID based on timestamp
@@ -547,6 +685,16 @@ class NotificationService {
);
return;
}
if (!_updateNotificationsEnabled) {
debugPrint(' [NotificationService] Update notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping update notification',
);
return;
}
try {
// Build notification title and body
@@ -617,4 +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';
enum AppThemeMode {
light,
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
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)
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
filled: true,
),
);
}
// Dark theme (Blue)
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
filled: true,
),
);
}
// SAR Red theme (Emergency/Alert tones)
static ThemeData get sarRedTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFFFF5252), // Bright red
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF8B0000), // Dark red
onPrimaryContainer: Color(0xFFFFCDD2),
secondary: Color(0xFFFF8A80),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFFB71C1C),
onSecondaryContainer: Color(0xFFFFCDD2),
tertiary: Color(0xFFFF6E40),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF1A0000), // Very dark red-tinted
onSurface: Color(0xFFFFEBEE),
surfaceContainerHighest: Color(0xFF2D0000),
onSurfaceVariant: Color(0xFFFFCDD2),
outline: Color(0xFFFF5252),
),
scaffoldBackgroundColor: const Color(0xFF1A0000),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF2D0000),
foregroundColor: Color(0xFFFFEBEE),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFFFF5252),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFFFF5252), // Bright red
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF8B0000), // Dark red
onPrimaryContainer: Color(0xFFFFCDD2),
secondary: Color(0xFFFF8A80),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFFB71C1C),
onSecondaryContainer: Color(0xFFFFCDD2),
tertiary: Color(0xFFFF6E40),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF1A0000), // Very dark red-tinted
onSurface: Color(0xFFFFEBEE),
surfaceContainerHighest: Color(0xFF2D0000),
onSurfaceVariant: Color(0xFFFFCDD2),
outline: Color(0xFFFF5252),
),
scaffoldBackgroundColor: const Color(0xFF1A0000),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF2D0000),
foregroundColor: Color(0xFFFFEBEE),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFFF5252), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFFF5252)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFFF5252)),
),
filled: true,
fillColor: const Color(0xFF2D0000),
),
filled: true,
fillColor: const Color(0xFF2D0000),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
),
),
),
);
@@ -125,58 +143,57 @@ class AppTheme {
// SAR Green theme (All Clear/Safe tones)
static ThemeData get sarGreenTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF69F0AE), // Bright green
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF00695C), // Dark teal-green
onPrimaryContainer: Color(0xFFB9F6CA),
secondary: Color(0xFF64FFDA),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF004D40),
onSecondaryContainer: Color(0xFFB9F6CA),
tertiary: Color(0xFF1DE9B6),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF001A12), // Very dark green-tinted
onSurface: Color(0xFFE8F5E9),
surfaceContainerHighest: Color(0xFF002D1F),
onSurfaceVariant: Color(0xFFB9F6CA),
outline: Color(0xFF69F0AE),
),
scaffoldBackgroundColor: const Color(0xFF001A12),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF002D1F),
foregroundColor: Color(0xFFE8F5E9),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF69F0AE),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF69F0AE), // Bright green
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF00695C), // Dark teal-green
onPrimaryContainer: Color(0xFFB9F6CA),
secondary: Color(0xFF64FFDA),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF004D40),
onSecondaryContainer: Color(0xFFB9F6CA),
tertiary: Color(0xFF1DE9B6),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF001A12), // Very dark green-tinted
onSurface: Color(0xFFE8F5E9),
surfaceContainerHighest: Color(0xFF002D1F),
onSurfaceVariant: Color(0xFFB9F6CA),
outline: Color(0xFF69F0AE),
),
scaffoldBackgroundColor: const Color(0xFF001A12),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF002D1F),
foregroundColor: Color(0xFFE8F5E9),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
),
filled: true,
fillColor: const Color(0xFF002D1F),
),
filled: true,
fillColor: const Color(0xFF002D1F),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
),
),
),
);
@@ -184,58 +201,57 @@ class AppTheme {
// SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF5C9FFF),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
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 double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
enum VoiceCodecKind {
codec2(0, 'Codec2');
const VoiceCodecKind(this.id, this.label);
final int id;
final String label;
}
/// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
mode700c(0, '700C', 8000, 100, 1600),
mode1200(1, '1200', 8000, 150, 1040),
mode2400(2, '2400', 8000, 300, 520),
mode1300(3, '1300', 8000, 175, 880),
mode1400(4, '1400', 8000, 175, 880),
mode1600(5, '1600', 8000, 200, 800),
mode3200(6, '3200', 8000, 400, 400);
const VoicePacketMode(
this.id,
this.label,
this.codec,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id;
final String label;
final VoiceCodecKind codec;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;

View File

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

View File

@@ -16,6 +16,7 @@ import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart';
import '../common/contact_avatar.dart';
import '../sensors/sensor_telemetry_card.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
@@ -325,10 +326,15 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room ||
contact.type == ContactType.channel;
final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room;
contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater ||
contact.type == ContactType.sensor;
final canAddToSensors =
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 isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
@@ -379,6 +385,17 @@ class ContactTile extends StatelessWidget {
_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)
ListTile(
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) {
final location = contact.displayLocation;
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 {
final String initialValue;
final String advertisedName;

View File

@@ -131,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? 'Direct route to ${widget.contact.displayName}'
? 'No relay path saved for ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall,
),
@@ -199,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concreteNodes
.length -
1
? Colors.red
: Colors.blue),
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -236,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
@@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -279,7 +268,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${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
? const Icon(Icons.sync_alt)
@@ -326,44 +315,18 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[];
if (trace.sender.node != null) {
entries.add(
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
return trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
target: _RouteEntryTarget.relayNode(entry.key),
);
}
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';
}).toList();
}
String _prefixKeyLabel(String publicKey) =>
@@ -527,20 +490,6 @@ class _ContactTraceResult {
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
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:
final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle();
@@ -570,26 +519,9 @@ class _RouteDisplayEntry {
});
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 {
final _RouteEntryKind kind;
@@ -597,8 +529,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index);
}

View File

@@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
try {
// Manually add the room contact to the radio's flash storage
await connectionProvider.addOrUpdateContact(widget.contact);
final addError = connectionProvider.error;
if (addError != null) {
throw Exception(addError);
}
debugPrint(
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',

View File

@@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget {
// Split contacts by type
final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[];
final sensors = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact
@@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget {
if (contact.isRepeater) {
repeaters.add(item);
} else if (contact.isSensor) {
sensors.add(item);
} else if (contact.isRoom) {
rooms.add(item);
} else {
@@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget {
}
// Sort each list by distance
persons.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));
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double));
persons.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(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text(
l10n.teamMembers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
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,
item,
Icons.groups,
Theme.of(context).colorScheme.primary,
)),
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...sensors.map(
(item) =>
_buildContactTile(context, item, Icons.sensors, Colors.green),
),
],
// Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[
@@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.repeaters,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...repeaters.map((item) => _buildContactTile(
context,
item,
Icons.router,
Colors.purple,
)),
...repeaters.map(
(item) =>
_buildContactTile(context, item, Icons.router, Colors.purple),
),
],
// Rooms section
if (rooms.isNotEmpty) ...[
@@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
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,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(
defaultIcon,
color: iconColor,
size: 24,
),
? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
: Icon(defaultIcon, color: iconColor, size: 24),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
@@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget {
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.grey),
),
],
),
@@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget {
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
@@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget {
// Calculate distance between two points (in meters)
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
final dLat = (lat2 - lat1) * 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(lat2 * pi / 180) *
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)!;
// Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading;

View File

@@ -12,98 +12,107 @@ class MapMarkers {
Function(Contact)? onContactTap,
double mapRotation = 0,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return contacts
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onContactTap != null) {
onContactTap(contact);
} else {
_showContactInfo(context, contact);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: _getLocationAgeColor(contact),
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),
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onContactTap != null) {
onContactTap(contact);
} else {
_showContactInfo(context, contact);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
],
),
padding: const EdgeInsets.all(6),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
_getContactTypeIcon(contact),
decoration: BoxDecoration(
color: _getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
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,
maxLines: 1,
textAlign: TextAlign.center,
),
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),
),
],
),
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>().toList();
);
})
.whereType<Marker>()
.toList();
}
static List<Marker> createSarMarkers(
@@ -133,7 +142,10 @@ class MapMarkers {
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: _getSarMarkerColor(marker),
borderRadius: BorderRadius.circular(3),
@@ -164,7 +176,7 @@ class MapMarkers {
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
@@ -172,7 +184,10 @@ class MapMarkers {
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
@@ -183,8 +198,12 @@ class MapMarkers {
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
debugPrint(' marker.notes: "${marker.notes}"');
debugPrint(' marker.type: ${marker.type}');
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
debugPrint(' marker.displayName: ${marker.displayName}');
debugPrint(
' marker.type.displayName: ${marker.type.displayName}',
);
debugPrint(
' marker.displayName: ${marker.displayName}',
);
return Text(
marker.displayName,
@@ -241,17 +260,25 @@ class MapMarkers {
_InfoRow(
'Voltage',
'${(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)
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
if (contact.telemetry?.temperature != null)
_InfoRow(
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
'Temperature',
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry?.humidity != null)
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'),
_InfoRow(
'Humidity',
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
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('Public Key', contact.publicKeyShort),
],
@@ -272,7 +299,10 @@ class MapMarkers {
builder: (context) => AlertDialog(
title: Row(
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),
Expanded(child: Text(marker.displayName)),
],
@@ -313,7 +343,9 @@ class MapMarkers {
static Color _getSarMarkerColor(SarMarker marker) {
// 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 hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16));
@@ -342,6 +374,8 @@ class MapMarkers {
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
@@ -357,6 +391,8 @@ class MapMarkers {
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
@@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(value),
),
Expanded(child: Text(value)),
],
),
);

View File

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

View File

@@ -17,9 +17,24 @@ import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
final Message? message;
final List<int>? packetPathOverride;
final String? descriptionOverride;
final String? noRelayMatchTextOverride;
const MessageTraceSheet({super.key, required this.message});
const MessageTraceSheet({super.key, required this.message})
: assert(message != null),
packetPathOverride = null,
descriptionOverride = null,
noRelayMatchTextOverride = null;
const MessageTraceSheet.packetPath({
super.key,
required List<int> packetPath,
this.descriptionOverride,
this.noRelayMatchTextOverride,
}) : message = null,
packetPathOverride = packetPath;
@override
State<MessageTraceSheet> createState() => _MessageTraceSheetState();
@@ -40,20 +55,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final storedPath = messagesProvider
.getMessageReceptionDetails(widget.message.id)
?.pathBytes;
final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
List<int>? packetPath = widget.packetPathOverride;
String? senderPrefix;
String? recipientPrefix;
if (widget.message case final message?) {
final storedPath = messagesProvider
.getMessageReceptionDetails(message.id)
?.pathBytes;
packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: message,
);
senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
recipientPrefix = message.recipientPublicKey != null
? _toPrefixHex(message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
}
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
@@ -66,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
);
if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) {
return trace;
}
@@ -153,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.mode == TraceMode.packetPath
? 'Route from packet path bytes'
: 'Route inferred from hop count (${widget.message.pathLen})',
widget.descriptionOverride ??
(trace.mode == TraceMode.packetPath
? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall,
),
),
@@ -222,14 +247,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concretePathNodes
.length -
1
? Colors.red
: Colors.blue),
Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -259,18 +277,19 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
'Relay path',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
widget.noRelayMatchTextOverride ??
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
@@ -286,11 +305,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
backgroundColor: Colors.blue,
child: Text(
'${entry.key + 1}',
style: const TextStyle(
@@ -302,7 +317,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${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
? const Icon(Icons.sync_alt)
@@ -356,6 +371,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) {
if (widget.message == null) {
return concrete;
}
if (concrete.length <= 1) return const [];
return concrete.sublist(1);
}
@@ -365,28 +383,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender.node != null)
_RouteDisplayEntry.fromResolved(
trace.sender,
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) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
return trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: entry.value,
@@ -398,54 +396,24 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
target: _RouteEntryTarget.pathNode(entry.key),
);
}).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
}
final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromResolved(
trace.sender,
target: const _RouteEntryTarget.sender(),
),
...trace.matchedPathNodes
.asMap()
.entries
.where((entry) => entry.value.node != null)
.map(
(entry) => _RouteDisplayEntry.fromResolved(
entry.value,
target: _RouteEntryTarget.pathNode(entry.key),
),
return 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)
_RouteDisplayEntry.fromResolved(
trace.recipient,
target: const _RouteEntryTarget.recipient(),
),
];
)
.toList();
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
@@ -521,7 +489,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
).reversed.toList();
);
final matched = _matchNodesFromPathHashes(
nodes: nodes,
localPublicKeys: localPublicKeys,
@@ -549,7 +517,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
nodes: nodes,
sender: senderNode.node,
recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen),
relayCount: math.max(0, widget.message!.pathLen),
);
final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode.node != null) senderNode,
@@ -731,22 +699,6 @@ class _TraceResult {
_TraceResult cycleEntry(_RouteEntryTarget target) {
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:
final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle();
@@ -796,7 +748,7 @@ class _RouteDisplayEntry {
}
}
enum _RouteEntryKind { sender, recipient, pathNode }
enum _RouteEntryKind { pathNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
@@ -804,8 +756,6 @@ class _RouteEntryTarget {
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index);
}

View File

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

View File

@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../providers/messages_provider.dart';
import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final List<DisplayMessageEntry> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
@@ -84,11 +85,13 @@ class MessagesContent extends StatelessWidget {
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final entry = messages[index];
final message = entry.message;
return MessageBubble(
key: ValueKey(message.id),
message: message,
receivedCopies: entry.occurrenceCount,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null

File diff suppressed because it is too large Load Diff

View File

@@ -631,14 +631,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@@ -795,8 +787,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "461f010"
resolved-ref: "461f010a2f1fcd343357c152228891e295d391be"
ref: f600789fac7f743b1c7db8aa24e441405413f669
resolved-ref: f600789fac7f743b1c7db8aa24e441405413f669
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"
@@ -1208,22 +1200,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
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
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0313.2+29
version: 2026.0315.1+31
environment:
sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client:
git:
url: https://github.com/dz0ny/meshcore_client.git
ref: "461f010"
ref: "f600789fac7f743b1c7db8aa24e441405413f669"
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter:
@@ -118,10 +118,6 @@ dependencies:
# XML parsing for GPX import/export
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)
nsd: ^4.0.3

View File

@@ -142,6 +142,7 @@ void main() {
final contactTypes = <ContactType>[
ContactType.chat,
ContactType.repeater,
ContactType.sensor,
ContactType.room,
ContactType.channel,
];

View File

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

View File

@@ -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(
WidgetTester tester, {
List<Contact> contacts = const [],
@@ -192,4 +211,14 @@ void main() {
expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget);
});
testWidgets('sensor contacts render in their own section', (tester) async {
await pumpContactsTab(
tester,
contacts: [buildSensor(seed: 60, name: 'WX Station')],
);
expect(find.text('Sensors'), findsOneWidget);
expect(find.text('WX Station'), findsOneWidget);
});
}

View File

@@ -115,9 +115,15 @@ void main() {
expect(find.text('1 pkt/min'), 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.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', (
@@ -174,6 +180,6 @@ void main() {
await tester.pump();
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_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
@@ -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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:typed_data';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[0] = secondByte;
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
});
@@ -104,4 +129,83 @@ void main() {
connectionProvider.dispose();
channelsProvider.dispose();
});
testWidgets('shows mention overlay above composer and inserts selection', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
]);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.byType(TextField).first);
await tester.enterText(find.byType(TextField).first, '@t');
await tester.pumpAndSettle();
expect(find.text('Tim'), findsOneWidget);
expect(find.text('Slane'), findsNothing);
await tester.tap(find.text('Tim'));
await tester.pumpAndSettle();
expect(find.text('@[Tim] '), findsOneWidget);
expect(find.text('Tim'), findsNothing);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
}

View File

@@ -0,0 +1,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));
});
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', () {
final buffer = ByteData(5);
buffer.setUint8(0, 0);

View File

@@ -43,9 +43,11 @@ void main() {
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restoredMessages = await storage.loadMessages();
expect(restoredDetails.keys, contains(message.id));
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
},
);
@@ -76,6 +78,7 @@ void main() {
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
receivedCopies: 3,
),
},
);
@@ -93,6 +96,7 @@ void main() {
expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400);
expect(restored.receivedCopies, 3);
expect(
restored.packetLoggedAt,
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 {
await tester.pumpWidget(
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 {
await pumpAvatar(
tester,
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
buildContact(
name: 'Command Net',
type: ContactType.channel,
secondByte: 3,
),
);
expect(find.text('CN'), findsOneWidget);
@@ -103,4 +109,15 @@ void main() {
expect(find.byType(CircleAvatar), findsOneWidget);
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/sensors_provider.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: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(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider<SensorsProvider>.value(
value: resolvedSensorsProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
@@ -104,4 +112,82 @@ void main() {
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
});
testWidgets('sensor contacts can be added to sensors', (tester) async {
await pumpTile(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Add to Sensors'), findsOneWidget);
});
testWidgets('sensor preview shows telemetry card', (tester) async {
final contact = buildContact(name: 'WX Station', type: ContactType.sensor)
.copyWith(
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
humidity: 58.0,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'__source_channel:humidity': 1,
'co2': 415.0,
'illuminance_2': 500.0,
'current_2': 0.015,
'power_2': 0.25,
'distance_2': 1.234,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
await pumpTile(tester, contact);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Preview'), findsOneWidget);
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
expect(find.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);
});
}