mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Compare commits
21 Commits
feat/updat
...
v2026.0315
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 | ||
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
62bf33c9d1 |
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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})';
|
||||
}
|
||||
}
|
||||
@@ -270,25 +270,10 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _checkLowBatteryAlerts() async {
|
||||
final recoveredIds = <String>{};
|
||||
|
||||
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
|
||||
if (deviceBattery != null &&
|
||||
deviceBattery > _lowBatteryResetThresholdPercent) {
|
||||
recoveredIds.add('device');
|
||||
}
|
||||
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (contact.isChannel) continue;
|
||||
final battery = contact.displayBattery;
|
||||
if (battery == null) continue;
|
||||
if (battery > _lowBatteryResetThresholdPercent) {
|
||||
recoveredIds.add(contact.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredIds.isNotEmpty) {
|
||||
_lowBatteryNotifiedNodeIds.removeAll(recoveredIds);
|
||||
_lowBatteryNotifiedNodeIds.remove('device');
|
||||
}
|
||||
|
||||
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
|
||||
@@ -302,19 +287,6 @@ class AppProvider with ChangeNotifier {
|
||||
isCurrentDevice: true,
|
||||
);
|
||||
}
|
||||
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (contact.isChannel) continue;
|
||||
final battery = contact.displayBattery;
|
||||
if (battery == null) continue;
|
||||
|
||||
await _notifyLowBatteryIfNeeded(
|
||||
nodeId: contact.publicKeyHex,
|
||||
nodeName: contact.displayName,
|
||||
batteryPercent: battery,
|
||||
isCurrentDevice: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _notifyLowBatteryIfNeeded({
|
||||
@@ -948,7 +920,7 @@ class AppProvider with ChangeNotifier {
|
||||
contactName: contact.advName.trim().isEmpty
|
||||
? null
|
||||
: contact.advName,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (contact.type == ContactType.repeater &&
|
||||
@@ -966,9 +938,6 @@ class AppProvider with ChangeNotifier {
|
||||
devicePublicKey: devicePublicKey,
|
||||
);
|
||||
unawaited(_pathHistoryService.recordLearnedPath(contact));
|
||||
|
||||
// Broadcast to SSE clients if server is running
|
||||
connectionProvider.broadcastContactToSseClients(contact);
|
||||
};
|
||||
|
||||
// When all contacts are received
|
||||
@@ -982,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
|
||||
@@ -1212,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');
|
||||
}
|
||||
@@ -1255,7 +1216,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1289,7 +1249,6 @@ class AppProvider with ChangeNotifier {
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
receptionDetailsSnapshot: receptionDetailsSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1314,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.
|
||||
@@ -1353,20 +1309,21 @@ class AppProvider with ChangeNotifier {
|
||||
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,
|
||||
);
|
||||
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'))
|
||||
@@ -1382,7 +1339,9 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(_requestRepeaterStatus(parsedAdvert.publicKey));
|
||||
unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey));
|
||||
}
|
||||
if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) {
|
||||
if (contactsProvider.shouldEnrichPendingAdvert(
|
||||
parsedAdvert.publicKey,
|
||||
)) {
|
||||
unawaited(connectionProvider.previewContact(parsedAdvert.publicKey));
|
||||
}
|
||||
return;
|
||||
@@ -1669,7 +1628,9 @@ class AppProvider with ChangeNotifier {
|
||||
unawaited(
|
||||
_notificationService.showContactDiscoveredNotification(
|
||||
contactKey: keyHex,
|
||||
contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName,
|
||||
contactName: contactsProvider
|
||||
.pendingAdvertByKey(publicKey)
|
||||
?.advName,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2248,10 +2209,9 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
final payload = utf8.decode(
|
||||
responseData.sublist(4),
|
||||
allowMalformed: true,
|
||||
).trim();
|
||||
final payload = utf8
|
||||
.decode(responseData.sublist(4), allowMalformed: true)
|
||||
.trim();
|
||||
if (payload.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
@@ -2351,10 +2311,9 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
String? advName;
|
||||
if (hasName && reader.remainingBytesCount > 0) {
|
||||
final decodedName = utf8.decode(
|
||||
reader.readRemainingBytes(),
|
||||
allowMalformed: true,
|
||||
).trim();
|
||||
final decodedName = utf8
|
||||
.decode(reader.readRemainingBytes(), allowMalformed: true)
|
||||
.trim();
|
||||
if (decodedName.isNotEmpty) {
|
||||
advName = decodedName;
|
||||
}
|
||||
|
||||
@@ -9,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';
|
||||
@@ -73,7 +71,6 @@ class _PendingContactRequest {
|
||||
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
|
||||
@@ -89,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;
|
||||
@@ -471,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) {
|
||||
@@ -495,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) {
|
||||
@@ -857,12 +840,19 @@ 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',
|
||||
@@ -1267,6 +1257,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
channelName: channelName,
|
||||
secret: secretBytes,
|
||||
);
|
||||
|
||||
// Reconcile local state from the device after the write completes,
|
||||
// matching the delete flow so channel/contact lists refresh immediately.
|
||||
await _activeService.getChannel(slotIdx);
|
||||
} catch (e) {
|
||||
if (_isChannelRefreshTimeoutError(e)) {
|
||||
debugPrint(
|
||||
@@ -1360,6 +1354,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
await _activeService.addOrUpdateContact(contact);
|
||||
} catch (e) {
|
||||
_error = 'Failed to add/update contact: $e';
|
||||
@@ -1367,6 +1362,22 @@ class ConnectionProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> importContactAdvert(Uint8List contactAdvertFrame) async {
|
||||
if (!_activeService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_error = null;
|
||||
await _activeService.importContact(contactAdvertFrame);
|
||||
} catch (e) {
|
||||
_error = 'Failed to import contact: $e';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send text message to contact
|
||||
///
|
||||
/// Returns true if the message was successfully sent to the BLE service.
|
||||
@@ -2578,110 +2589,6 @@ 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();
|
||||
@@ -2689,7 +2596,6 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_stopAckCleanupTimer();
|
||||
_bleService.dispose();
|
||||
_tcpService?.dispose();
|
||||
_sseServer.stopServer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,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);
|
||||
|
||||
@@ -553,6 +556,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
_contacts[contact.publicKeyHex] = updatedContact;
|
||||
_pendingAdverts.remove(contact.publicKeyHex);
|
||||
debugPrint(
|
||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||
);
|
||||
@@ -581,6 +585,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
incomingContact: contact,
|
||||
existingContact: existingContact,
|
||||
);
|
||||
_pendingAdverts.remove(contact.publicKeyHex);
|
||||
}
|
||||
if (excluded > 0) {
|
||||
debugPrint(
|
||||
@@ -870,10 +875,19 @@ class ContactsProvider with ChangeNotifier {
|
||||
telemetry.gpsLocation,
|
||||
)) {
|
||||
debugPrint(
|
||||
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
||||
' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps',
|
||||
);
|
||||
}
|
||||
telemetry = mergedTelemetry;
|
||||
telemetry = ContactTelemetry(
|
||||
gpsLocation: telemetry.gpsLocation,
|
||||
batteryPercentage: mergedTelemetry.batteryPercentage,
|
||||
batteryMilliVolts: mergedTelemetry.batteryMilliVolts,
|
||||
temperature: mergedTelemetry.temperature,
|
||||
timestamp: mergedTelemetry.timestamp,
|
||||
humidity: mergedTelemetry.humidity,
|
||||
pressure: mergedTelemetry.pressure,
|
||||
extraSensorData: telemetry.extraSensorData,
|
||||
);
|
||||
}
|
||||
|
||||
// Update contact with new telemetry AND last seen time
|
||||
@@ -988,10 +1002,10 @@ class ContactsProvider with ChangeNotifier {
|
||||
// last known reading for any field that is omitted in the incoming update.
|
||||
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||||
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||||
final mergedExtraSensorData = <String, dynamic>{
|
||||
...?existingTelemetry?.extraSensorData,
|
||||
...?incomingTelemetry.extraSensorData,
|
||||
};
|
||||
final mergedExtraSensorData = _mergeExtraSensorData(
|
||||
existingTelemetry?.extraSensorData,
|
||||
incomingTelemetry.extraSensorData,
|
||||
);
|
||||
|
||||
return ContactTelemetry(
|
||||
gpsLocation: incomingGps ?? previousGps,
|
||||
@@ -1012,6 +1026,49 @@ class ContactsProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mergeExtraSensorData(
|
||||
Map<String, dynamic>? existingExtraSensorData,
|
||||
Map<String, dynamic>? incomingExtraSensorData,
|
||||
) {
|
||||
final merged = <String, dynamic>{...?existingExtraSensorData};
|
||||
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
final incomingMetricFamilies = incomingExtraSensorData.keys
|
||||
.map(_telemetryMetricFamilyForKey)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
if (incomingMetricFamilies.isNotEmpty) {
|
||||
merged.removeWhere((key, _) {
|
||||
final family = _telemetryMetricFamilyForKey(key);
|
||||
return family != null && incomingMetricFamilies.contains(family);
|
||||
});
|
||||
}
|
||||
|
||||
merged.addAll(incomingExtraSensorData);
|
||||
return merged;
|
||||
}
|
||||
|
||||
String? _telemetryMetricFamilyForKey(String key) {
|
||||
const sourcePrefix = '__source_channel:';
|
||||
if (key.startsWith(sourcePrefix)) {
|
||||
return key.substring(sourcePrefix.length);
|
||||
}
|
||||
|
||||
final separatorIndex = key.lastIndexOf('_');
|
||||
if (separatorIndex <= 0 || separatorIndex == key.length - 1) {
|
||||
return key;
|
||||
}
|
||||
|
||||
final suffix = key.substring(separatorIndex + 1);
|
||||
if (int.tryParse(suffix) == null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return key.substring(0, separatorIndex);
|
||||
}
|
||||
|
||||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||||
return (coordinate * 1e6).round();
|
||||
}
|
||||
@@ -1526,6 +1583,7 @@ class ContactsProvider with ChangeNotifier {
|
||||
return {
|
||||
'chat': chatContacts.length,
|
||||
'repeater': repeaters.length,
|
||||
'sensor': sensorContacts.length,
|
||||
'room': rooms.length,
|
||||
'total': contacts.length,
|
||||
};
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
386
lib/screens/add_contact_screen.dart
Normal file
386
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,386 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/connection_provider.dart';
|
||||
|
||||
class AddContactScreen extends StatefulWidget {
|
||||
const AddContactScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||
}
|
||||
|
||||
class _AddContactScreenState extends State<AddContactScreen> {
|
||||
final TextEditingController _advertController = TextEditingController();
|
||||
bool _isImporting = false;
|
||||
bool _importSucceeded = false;
|
||||
String? _validationError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadClipboardIfPresent();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_advertController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadClipboardIfPresent() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text?.trim();
|
||||
if (!mounted || text == null || text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (_normalizeAdvertText(text) == null) {
|
||||
return;
|
||||
}
|
||||
_advertController.text = text;
|
||||
}
|
||||
|
||||
String? _normalizeAdvertText(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = trimmed;
|
||||
if (normalized.startsWith('meshcore://')) {
|
||||
normalized = normalized.substring('meshcore://'.length);
|
||||
}
|
||||
|
||||
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
|
||||
if (!isHex || normalized.length.isOdd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
return Uint8List.fromList(bytes);
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final text = clipboardData?.text;
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_advertController.text = text.trim();
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _importContact() async {
|
||||
final normalized = _normalizeAdvertText(_advertController.text);
|
||||
if (normalized == null) {
|
||||
setState(() {
|
||||
_validationError =
|
||||
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final advertBytes = _hexToBytes(normalized);
|
||||
if (advertBytes.length < 98) {
|
||||
setState(() {
|
||||
_validationError =
|
||||
'Advert is too short. Expected exported contact data.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = true;
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
await connectionProvider.importContactAdvert(advertBytes);
|
||||
final importError = connectionProvider.error;
|
||||
|
||||
if (importError == null) {
|
||||
await connectionProvider.getContacts();
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isImporting = false;
|
||||
});
|
||||
|
||||
if (importError != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(importError)));
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Contact imported')));
|
||||
setState(() {
|
||||
_importSucceeded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final normalized = _normalizeAdvertText(_advertController.text);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Import Contact')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person_add_alt_1_outlined,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Import a shared contact advert',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
_ImportHintChip(
|
||||
icon: Icons.link_outlined,
|
||||
label: 'Accepts share links',
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.code_outlined,
|
||||
label: 'Supports raw hex',
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.content_paste_go_outlined,
|
||||
label: 'Clipboard-friendly',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Advert payload',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'You can paste the full share link or only the hex payload.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _advertController,
|
||||
minLines: 5,
|
||||
maxLines: 9,
|
||||
onChanged: (_) {
|
||||
if (_validationError != null || _importSucceeded) {
|
||||
setState(() {
|
||||
_importSucceeded = false;
|
||||
_validationError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Contact advert',
|
||||
hintText: 'Paste a share link or hex advert',
|
||||
alignLabelWithHint: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _validationError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
normalized == null
|
||||
? 'No valid advert detected yet'
|
||||
: 'Advert size: ${normalized.length ~/ 2} bytes',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||
icon: const Icon(Icons.content_paste_go_outlined),
|
||||
label: const Text('Paste'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: (_isImporting || _importSucceeded)
|
||||
? null
|
||||
: _importContact,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
icon: _isImporting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _importSucceeded
|
||||
? const Icon(Icons.check_circle_outline)
|
||||
: const Icon(Icons.person_add_alt_1_outlined),
|
||||
label: Text(
|
||||
_isImporting
|
||||
? 'Importing...'
|
||||
: _importSucceeded
|
||||
? 'Imported'
|
||||
: 'Import Contact',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_importSucceeded)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Contact imported. Paste another advert or edit the current one to import again.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImportHintChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _ImportHintChip({required this.icon, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: theme.textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -37,6 +38,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
final Map<ContactSection, String> _sectionFilters = {
|
||||
ContactSection.teamMembers: '',
|
||||
ContactSection.repeaters: '',
|
||||
ContactSection.sensors: '',
|
||||
ContactSection.rooms: '',
|
||||
ContactSection.channels: '',
|
||||
};
|
||||
@@ -44,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,
|
||||
};
|
||||
|
||||
@@ -387,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,
|
||||
@@ -539,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,
|
||||
@@ -580,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,
|
||||
@@ -608,12 +634,14 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
: const <_RenderedSavedGroup>[];
|
||||
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;
|
||||
|
||||
@@ -638,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('Import Contact'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -743,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(
|
||||
@@ -811,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('Import 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1172,7 +1262,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
|
||||
enum ContactSortMode { lastSeen, distance }
|
||||
|
||||
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
||||
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
|
||||
|
||||
class _RenderedSavedGroup {
|
||||
final SavedContactGroup group;
|
||||
|
||||
@@ -125,15 +125,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
await connectionProvider.getContact(advert.publicKey);
|
||||
if (connectionProvider.error == 'Not found') {
|
||||
connectionProvider.clearError();
|
||||
final fallbackContact = _contactFromPendingAdvert(advert);
|
||||
await connectionProvider.addOrUpdateContact(fallbackContact);
|
||||
contactsProvider.addOrUpdateContact(
|
||||
fallbackContact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
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);
|
||||
@@ -200,6 +214,37 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -375,207 +420,203 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
),
|
||||
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>[];
|
||||
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,
|
||||
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: [
|
||||
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 Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
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: 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(height: 10),
|
||||
Text(
|
||||
detailLines.join('\n'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -629,9 +670,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
metric.valueLabel,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -802,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) {
|
||||
|
||||
@@ -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';
|
||||
@@ -1006,15 +1007,12 @@ class _LiveTrafficPacketDetails {
|
||||
}
|
||||
|
||||
static String _packetHash(List<int> bytes) {
|
||||
const fnvOffset = 0xcbf29ce484222325;
|
||||
const fnvPrime = 0x100000001b3;
|
||||
const mask = 0xFFFFFFFFFFFFFFFF;
|
||||
var hash = fnvOffset;
|
||||
for (final byte in bytes) {
|
||||
hash ^= byte & 0xFF;
|
||||
hash = (hash * fnvPrime) & mask;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(16, '0').toUpperCase();
|
||||
final digest = md5.convert(bytes).bytes;
|
||||
return digest
|
||||
.take(8)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,6 @@ 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';
|
||||
@@ -80,6 +79,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _openMapInFullscreen = false;
|
||||
bool _messageNotificationsEnabled = true;
|
||||
bool _sarNotificationsEnabled = true;
|
||||
bool _discoveryNotificationsEnabled = true;
|
||||
bool _updateNotificationsEnabled = true;
|
||||
bool _muteForegroundNotifications = true;
|
||||
bool _isDeveloperModeEnabled = false;
|
||||
@@ -149,6 +149,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
setState(() {
|
||||
_messageNotificationsEnabled = service.messageNotificationsEnabled;
|
||||
_sarNotificationsEnabled = service.sarNotificationsEnabled;
|
||||
_discoveryNotificationsEnabled = service.discoveryNotificationsEnabled;
|
||||
_updateNotificationsEnabled = service.updateNotificationsEnabled;
|
||||
_muteForegroundNotifications = service.muteForegroundNotifications;
|
||||
});
|
||||
@@ -1051,6 +1052,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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'),
|
||||
@@ -1536,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
]),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
_buildSectionHeader('Network Sharing'),
|
||||
const ConnectionModeSelector(),
|
||||
],
|
||||
|
||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||
_buildSettingsCard([
|
||||
SwitchListTile(
|
||||
@@ -1863,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
Switch(
|
||||
value: _showCurrentImagePreview,
|
||||
onChanged: (value) {
|
||||
setState(() => _showCurrentImagePreview = value);
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ class NotificationService {
|
||||
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';
|
||||
|
||||
@@ -23,6 +24,7 @@ class NotificationService {
|
||||
bool _permissionGranted = false;
|
||||
bool _messageNotificationsEnabled = true;
|
||||
bool _sarNotificationsEnabled = true;
|
||||
bool _discoveryNotificationsEnabled = true;
|
||||
bool _updateNotificationsEnabled = true;
|
||||
bool _muteForegroundNotifications = true;
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
@@ -63,6 +65,7 @@ class NotificationService {
|
||||
|
||||
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;
|
||||
@@ -175,6 +178,8 @@ class NotificationService {
|
||||
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;
|
||||
}
|
||||
@@ -191,6 +196,12 @@ class NotificationService {
|
||||
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();
|
||||
@@ -854,7 +865,7 @@ class NotificationService {
|
||||
}) async {
|
||||
if (!_isInitialized) return false;
|
||||
if (!_permissionGranted) return false;
|
||||
if (!_messageNotificationsEnabled) return false;
|
||||
if (!_discoveryNotificationsEnabled) return false;
|
||||
if (_shouldSuppressForegroundNotifications()) return false;
|
||||
|
||||
final shortKey = contactKey.length > 12
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shelf/shelf.dart' as shelf;
|
||||
import 'package:shelf/shelf_io.dart' as io;
|
||||
import 'package:nsd/nsd.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sse_server_config.dart';
|
||||
import 'network_scanner_service.dart';
|
||||
|
||||
/// SSE Server Service
|
||||
///
|
||||
/// Provides a web server with SSE (Server-Sent Events) endpoints for
|
||||
/// real-time message and contact updates, enabling multiple app instances
|
||||
/// to share a single MeshCore BLE device.
|
||||
///
|
||||
/// Endpoints:
|
||||
/// - GET /sse/messages - SSE stream for message updates
|
||||
/// - GET /sse/contacts - SSE stream for contact updates
|
||||
/// - POST /api/messages - Send message
|
||||
/// - POST /api/messages/channel - Send channel message
|
||||
/// - POST /api/contacts/sync - Trigger contact sync
|
||||
/// - GET /api/messages/history - Get all messages
|
||||
/// - GET /api/contacts - Get all contacts
|
||||
/// - GET /api/status - Server health check
|
||||
class SseServerService {
|
||||
HttpServer? _server;
|
||||
SseServerConfig? _config;
|
||||
Registration? _bonjourRegistration;
|
||||
|
||||
/// Active SSE connections for messages
|
||||
final Set<StreamController<String>> _messageStreams = {};
|
||||
|
||||
/// Active SSE connections for contacts
|
||||
final Set<StreamController<String>> _contactStreams = {};
|
||||
|
||||
/// Message history (for new clients)
|
||||
final List<Message> _messageHistory = [];
|
||||
|
||||
/// Contact list (for new clients)
|
||||
final Map<String, Contact> _contacts = {};
|
||||
|
||||
/// Timer for cleaning up dead connections
|
||||
Timer? _cleanupTimer;
|
||||
|
||||
/// Device name (for status endpoint)
|
||||
String? _deviceName;
|
||||
|
||||
/// Set device name
|
||||
void setDeviceName(String? name) {
|
||||
_deviceName = name;
|
||||
debugPrint('📝 [SseServer] Device name set to: $name');
|
||||
}
|
||||
|
||||
/// Callback for when a client requests to send a message
|
||||
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
|
||||
|
||||
/// Callback for when a client requests to send a channel message
|
||||
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
|
||||
|
||||
/// Callback for when a client requests contact sync
|
||||
Future<void> Function()? onSyncContacts;
|
||||
|
||||
/// Check if server is running
|
||||
bool get isRunning => _server != null;
|
||||
|
||||
/// Get current configuration
|
||||
SseServerConfig? get config => _config;
|
||||
|
||||
/// Get number of connected clients
|
||||
int get connectedClients => _messageStreams.length;
|
||||
|
||||
/// CORS middleware
|
||||
static shelf.Middleware get _corsHeaders {
|
||||
return shelf.createMiddleware(
|
||||
responseHandler: (shelf.Response response) {
|
||||
return response.change(
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'Origin, Content-Type, Authorization',
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Start the SSE server
|
||||
Future<void> startServer(SseServerConfig config) async {
|
||||
if (_server != null) {
|
||||
debugPrint('⚠️ [SseServer] Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
try {
|
||||
debugPrint(
|
||||
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
|
||||
);
|
||||
|
||||
// Create shelf handler with CORS support
|
||||
final handler = const shelf.Pipeline()
|
||||
.addMiddleware(_corsHeaders)
|
||||
.addMiddleware(shelf.logRequests())
|
||||
.addHandler(_handleRequest);
|
||||
|
||||
// Start HTTP server
|
||||
_server = await io.serve(handler, config.host, config.port);
|
||||
|
||||
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
||||
|
||||
// Start cleanup timer for dead connections
|
||||
_startCleanupTimer();
|
||||
|
||||
// Register Bonjour/mDNS service
|
||||
await _registerBonjourService(config);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Failed to start server: $e');
|
||||
_server = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register Bonjour/mDNS service for network discovery
|
||||
Future<void> _registerBonjourService(SseServerConfig config) async {
|
||||
try {
|
||||
debugPrint(
|
||||
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
|
||||
);
|
||||
|
||||
_bonjourRegistration = await register(
|
||||
const Service(
|
||||
name: 'MeshCore SSE Server',
|
||||
type: NetworkScannerService.serviceType,
|
||||
port: 0, // Will be set dynamically
|
||||
),
|
||||
);
|
||||
|
||||
// Update with actual port
|
||||
if (_bonjourRegistration != null) {
|
||||
// Unregister and re-register with correct port
|
||||
await unregister(_bonjourRegistration!);
|
||||
_bonjourRegistration = await register(
|
||||
Service(
|
||||
name: 'MeshCore SSE Server',
|
||||
type: NetworkScannerService.serviceType,
|
||||
port: config.port,
|
||||
),
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [SseServer] Bonjour service registered on port ${config.port}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
||||
// Don't throw - server can still work without Bonjour
|
||||
}
|
||||
}
|
||||
|
||||
/// Start cleanup timer to remove dead connections
|
||||
void _startCleanupTimer() {
|
||||
_cleanupTimer?.cancel();
|
||||
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
|
||||
_cleanupDeadConnections();
|
||||
});
|
||||
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
|
||||
}
|
||||
|
||||
/// Clean up dead/closed connections
|
||||
void _cleanupDeadConnections() {
|
||||
// Clean up message streams
|
||||
final deadMessageStreams = _messageStreams
|
||||
.where((s) => s.isClosed)
|
||||
.toList();
|
||||
for (final stream in deadMessageStreams) {
|
||||
_messageStreams.remove(stream);
|
||||
}
|
||||
|
||||
// Clean up contact streams
|
||||
final deadContactStreams = _contactStreams
|
||||
.where((s) => s.isClosed)
|
||||
.toList();
|
||||
for (final stream in deadContactStreams) {
|
||||
_contactStreams.remove(stream);
|
||||
}
|
||||
|
||||
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
|
||||
);
|
||||
debugPrint(
|
||||
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the SSE server
|
||||
Future<void> stopServer() async {
|
||||
if (_server == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🛑 [SseServer] Stopping server...');
|
||||
|
||||
// Stop cleanup timer
|
||||
_cleanupTimer?.cancel();
|
||||
_cleanupTimer = null;
|
||||
|
||||
// Close all SSE streams
|
||||
for (final stream in _messageStreams) {
|
||||
await stream.close();
|
||||
}
|
||||
_messageStreams.clear();
|
||||
|
||||
for (final stream in _contactStreams) {
|
||||
await stream.close();
|
||||
}
|
||||
_contactStreams.clear();
|
||||
|
||||
// Unregister Bonjour service
|
||||
if (_bonjourRegistration != null) {
|
||||
try {
|
||||
await unregister(_bonjourRegistration!);
|
||||
debugPrint('✅ [SseServer] Bonjour service unregistered');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
|
||||
}
|
||||
_bonjourRegistration = null;
|
||||
}
|
||||
|
||||
// Close HTTP server
|
||||
await _server!.close(force: true);
|
||||
_server = null;
|
||||
_config = null;
|
||||
|
||||
debugPrint('✅ [SseServer] Server stopped');
|
||||
}
|
||||
|
||||
/// Main request handler
|
||||
Future<shelf.Response> _handleRequest(shelf.Request request) async {
|
||||
// Check authentication if token is configured
|
||||
if (_config?.authToken != null) {
|
||||
final authHeader = request.headers['authorization'];
|
||||
if (authHeader != 'Bearer ${_config!.authToken}') {
|
||||
return shelf.Response.forbidden('Invalid authentication token');
|
||||
}
|
||||
}
|
||||
|
||||
final path = request.url.path;
|
||||
final method = request.method;
|
||||
|
||||
debugPrint('📨 [SseServer] $method /$path');
|
||||
|
||||
// Route requests
|
||||
if (method == 'GET' && path == 'sse/messages') {
|
||||
return _handleSseMessages(request);
|
||||
} else if (method == 'GET' && path == 'sse/contacts') {
|
||||
return _handleSseContacts(request);
|
||||
} else if (method == 'POST' && path == 'api/messages') {
|
||||
return _handlePostMessage(request);
|
||||
} else if (method == 'POST' && path == 'api/messages/channel') {
|
||||
return _handlePostChannelMessage(request);
|
||||
} else if (method == 'POST' && path == 'api/contacts/sync') {
|
||||
return _handlePostContactsSync(request);
|
||||
} else if (method == 'GET' && path == 'api/messages/history') {
|
||||
return _handleGetMessageHistory(request);
|
||||
} else if (method == 'GET' && path == 'api/contacts') {
|
||||
return _handleGetContacts(request);
|
||||
} else if (method == 'GET' && path == 'api/status') {
|
||||
return _handleGetStatus(request);
|
||||
} else if (method == 'GET' && path == '') {
|
||||
return _handleRoot(request);
|
||||
}
|
||||
|
||||
return shelf.Response.notFound('Not found');
|
||||
}
|
||||
|
||||
/// Handle SSE messages stream
|
||||
shelf.Response _handleSseMessages(shelf.Request request) {
|
||||
return request.hijack((channel) async {
|
||||
debugPrint(
|
||||
'📥 [SseServer] New SSE client connected (messages) via hijack',
|
||||
);
|
||||
|
||||
// Set up the sink for sending data
|
||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||
|
||||
// Send SSE headers
|
||||
sink.add('HTTP/1.1 200 OK\r\n');
|
||||
sink.add('Content-Type: text/event-stream\r\n');
|
||||
sink.add('Cache-Control: no-cache\r\n');
|
||||
sink.add('Connection: keep-alive\r\n');
|
||||
sink.add('\r\n');
|
||||
|
||||
// Create controller for this connection
|
||||
final controller = StreamController<String>();
|
||||
_messageStreams.add(controller);
|
||||
|
||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
||||
|
||||
// Send initial connection event
|
||||
sink.add(': connected\n\n');
|
||||
|
||||
// Send initial message history
|
||||
for (final message in _messageHistory) {
|
||||
final event = _formatSseEvent('message', _messageToJson(message));
|
||||
sink.add(event);
|
||||
}
|
||||
|
||||
// Start keep-alive timer
|
||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||
timer,
|
||||
) {
|
||||
try {
|
||||
sink.add(': keepalive\n\n');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to controller for new messages to broadcast
|
||||
final subscription = controller.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
sink.add(data);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('📤 [SseServer] Controller stream closed');
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for channel to close
|
||||
await channel.stream.drain();
|
||||
|
||||
// Cleanup
|
||||
keepAliveTimer.cancel();
|
||||
await subscription.cancel();
|
||||
_messageStreams.remove(controller);
|
||||
await controller.close();
|
||||
|
||||
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
|
||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle SSE contacts stream
|
||||
shelf.Response _handleSseContacts(shelf.Request request) {
|
||||
return request.hijack((channel) async {
|
||||
debugPrint(
|
||||
'📥 [SseServer] New SSE client connected (contacts) via hijack',
|
||||
);
|
||||
|
||||
// Set up the sink for sending data
|
||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
||||
|
||||
// Send SSE headers
|
||||
sink.add('HTTP/1.1 200 OK\r\n');
|
||||
sink.add('Content-Type: text/event-stream\r\n');
|
||||
sink.add('Cache-Control: no-cache\r\n');
|
||||
sink.add('Connection: keep-alive\r\n');
|
||||
sink.add('\r\n');
|
||||
|
||||
// Create controller for this connection
|
||||
final controller = StreamController<String>();
|
||||
_contactStreams.add(controller);
|
||||
|
||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
||||
|
||||
// Send initial connection event
|
||||
sink.add(': connected\n\n');
|
||||
|
||||
// Send initial contact list
|
||||
for (final contact in _contacts.values) {
|
||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
||||
sink.add(event);
|
||||
}
|
||||
|
||||
// Start keep-alive timer
|
||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
||||
timer,
|
||||
) {
|
||||
try {
|
||||
sink.add(': keepalive\n\n');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to controller for new messages to broadcast
|
||||
final subscription = controller.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
sink.add(data);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('📤 [SseServer] Controller stream closed');
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for channel to close
|
||||
await channel.stream.drain();
|
||||
|
||||
// Cleanup
|
||||
keepAliveTimer.cancel();
|
||||
await subscription.cancel();
|
||||
_contactStreams.remove(controller);
|
||||
await controller.close();
|
||||
|
||||
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
|
||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle POST message request
|
||||
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final recipientPublicKey = json['recipientPublicKey'] as String;
|
||||
final text = json['text'] as String;
|
||||
|
||||
if (onSendMessage == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Send message callback not configured'}),
|
||||
);
|
||||
}
|
||||
|
||||
final success = await onSendMessage!(recipientPublicKey, text);
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': success}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST message: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle POST channel message request
|
||||
Future<shelf.Response> _handlePostChannelMessage(
|
||||
shelf.Request request,
|
||||
) async {
|
||||
try {
|
||||
final body = await request.readAsString();
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final channelIdx = json['channelIdx'] as int;
|
||||
final text = json['text'] as String;
|
||||
|
||||
if (onSendChannelMessage == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({
|
||||
'error': 'Send channel message callback not configured',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await onSendChannelMessage!(channelIdx, text);
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': true}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle POST contacts sync request
|
||||
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
|
||||
try {
|
||||
if (onSyncContacts == null) {
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
|
||||
);
|
||||
}
|
||||
|
||||
await onSyncContacts!();
|
||||
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'success': true}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
|
||||
return shelf.Response.internalServerError(
|
||||
body: jsonEncode({'error': e.toString()}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle GET message history request
|
||||
shelf.Response _handleGetMessageHistory(shelf.Request request) {
|
||||
final messages = _messageHistory.map(_messageToJson).toList();
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'messages': messages}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle GET contacts request
|
||||
shelf.Response _handleGetContacts(shelf.Request request) {
|
||||
final contacts = _contacts.values.map(_contactToJson).toList();
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({'contacts': contacts}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle GET status request
|
||||
shelf.Response _handleGetStatus(shelf.Request request) {
|
||||
return shelf.Response.ok(
|
||||
jsonEncode({
|
||||
'status': 'running',
|
||||
'connectedClients': connectedClients,
|
||||
'messageCount': _messageHistory.length,
|
||||
'contactCount': _contacts.length,
|
||||
'deviceName': _deviceName,
|
||||
}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle root request (landing page)
|
||||
shelf.Response _handleRoot(shelf.Request request) {
|
||||
final html = '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MeshCore SAR - SSE Server</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; }
|
||||
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
|
||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
|
||||
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 MeshCore SAR Server</h1>
|
||||
<div class="status">✅ Server is running</div>
|
||||
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
|
||||
|
||||
<h2>📡 SSE Endpoints</h2>
|
||||
<div class="endpoint">GET /sse/messages</div>
|
||||
<div class="endpoint">GET /sse/contacts</div>
|
||||
|
||||
<h2>🔧 API Endpoints</h2>
|
||||
<div class="endpoint">POST /api/messages</div>
|
||||
<div class="endpoint">POST /api/messages/channel</div>
|
||||
<div class="endpoint">POST /api/contacts/sync</div>
|
||||
<div class="endpoint">GET /api/messages/history</div>
|
||||
<div class="endpoint">GET /api/contacts</div>
|
||||
<div class="endpoint">GET /api/status</div>
|
||||
|
||||
<h2>📊 Stats</h2>
|
||||
<p>Connected clients: <strong id="clients">Loading...</strong></p>
|
||||
<p>Messages: <strong id="messages">Loading...</strong></p>
|
||||
<p>Contacts: <strong id="contacts">Loading...</strong></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function updateStats() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('clients').textContent = data.connectedClients;
|
||||
document.getElementById('messages').textContent = data.messageCount;
|
||||
document.getElementById('contacts').textContent = data.contactCount;
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stats:', e);
|
||||
}
|
||||
}
|
||||
updateStats();
|
||||
setInterval(updateStats, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
|
||||
}
|
||||
|
||||
/// Broadcast a new message to all SSE clients
|
||||
void broadcastMessage(Message message) {
|
||||
// Add to history (limit to 1000 messages)
|
||||
_messageHistory.add(message);
|
||||
if (_messageHistory.length > 1000) {
|
||||
_messageHistory.removeAt(0);
|
||||
}
|
||||
|
||||
// Broadcast to all connected clients
|
||||
final event = _formatSseEvent('message', _messageToJson(message));
|
||||
final deadStreams = <StreamController<String>>[];
|
||||
|
||||
for (final stream in _messageStreams) {
|
||||
if (stream.isClosed) {
|
||||
deadStreams.add(stream);
|
||||
} else {
|
||||
try {
|
||||
stream.add(event);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||
);
|
||||
deadStreams.add(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead streams
|
||||
for (final stream in deadStreams) {
|
||||
_messageStreams.remove(stream);
|
||||
stream.close().catchError(
|
||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
if (deadStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
|
||||
);
|
||||
}
|
||||
|
||||
/// Broadcast a new or updated contact to all SSE clients
|
||||
void broadcastContact(Contact contact) {
|
||||
// Update contact list
|
||||
_contacts[contact.publicKeyHex] = contact;
|
||||
|
||||
// Broadcast to all connected clients
|
||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
||||
final deadStreams = <StreamController<String>>[];
|
||||
|
||||
for (final stream in _contactStreams) {
|
||||
if (stream.isClosed) {
|
||||
deadStreams.add(stream);
|
||||
} else {
|
||||
try {
|
||||
stream.add(event);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
||||
);
|
||||
deadStreams.add(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead streams
|
||||
for (final stream in deadStreams) {
|
||||
_contactStreams.remove(stream);
|
||||
stream.close().catchError(
|
||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
||||
);
|
||||
}
|
||||
|
||||
if (deadStreams.isNotEmpty) {
|
||||
debugPrint(
|
||||
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
|
||||
);
|
||||
}
|
||||
|
||||
/// Format SSE event
|
||||
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
|
||||
final jsonData = jsonEncode(data);
|
||||
return 'event: $eventType\ndata: $jsonData\n\n';
|
||||
}
|
||||
|
||||
/// Convert Message to JSON
|
||||
Map<String, dynamic> _messageToJson(Message message) {
|
||||
return {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
'isSarMarker': message.isSarMarker,
|
||||
'sarGpsCoordinates': message.sarGpsCoordinates != null
|
||||
? {
|
||||
'latitude': message.sarGpsCoordinates!.latitude,
|
||||
'longitude': message.sarGpsCoordinates!.longitude,
|
||||
}
|
||||
: null,
|
||||
'sarNotes': message.sarNotes,
|
||||
'sarCustomEmoji': message.sarCustomEmoji,
|
||||
'sarColorIndex': message.sarColorIndex,
|
||||
'receivedAt': message.receivedAt.toIso8601String(),
|
||||
'senderName': message.senderName,
|
||||
'deliveryStatus': message.deliveryStatus.name,
|
||||
'expectedAckTag': message.expectedAckTag,
|
||||
'suggestedTimeoutMs': message.suggestedTimeoutMs,
|
||||
'roundTripTimeMs': message.roundTripTimeMs,
|
||||
'deliveredAt': message.deliveredAt?.toIso8601String(),
|
||||
'recipientPublicKey': message.recipientPublicKey?.toList(),
|
||||
'retryAttempt': message.retryAttempt,
|
||||
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
|
||||
'usedFloodFallback': message.usedFloodFallback,
|
||||
'isRead': message.isRead,
|
||||
'echoCount': message.echoCount,
|
||||
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
||||
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
||||
'isDrawing': message.isDrawing,
|
||||
'drawingId': message.drawingId,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert Contact to JSON
|
||||
Map<String, dynamic> _contactToJson(Contact contact) {
|
||||
return {
|
||||
'publicKey': contact.publicKey.toList(),
|
||||
'publicKeyHex': contact.publicKeyHex,
|
||||
'type': contact.type.value,
|
||||
'flags': contact.flags,
|
||||
'outPathLen': contact.outPathLen,
|
||||
'outPath': contact.outPath.toList(),
|
||||
'advName': contact.advName,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
'lastMod': contact.lastMod,
|
||||
'telemetry': contact.telemetry != null
|
||||
? {
|
||||
'batteryPercentage': contact.telemetry!.batteryPercentage,
|
||||
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
|
||||
'temperature': contact.telemetry!.temperature,
|
||||
'humidity': contact.telemetry!.humidity,
|
||||
'pressure': contact.telemetry!.pressure,
|
||||
'gpsLocation': contact.telemetry!.gpsLocation != null
|
||||
? {
|
||||
'latitude': contact.telemetry!.gpsLocation!.latitude,
|
||||
'longitude': contact.telemetry!.gpsLocation!.longitude,
|
||||
}
|
||||
: null,
|
||||
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Clear message history
|
||||
void clearMessageHistory() {
|
||||
_messageHistory.clear();
|
||||
}
|
||||
|
||||
/// Clear contact list
|
||||
void clearContacts() {
|
||||
_contacts.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -327,10 +328,13 @@ class ContactTile extends StatelessWidget {
|
||||
final canSetPath =
|
||||
contact.type == ContactType.chat ||
|
||||
contact.type == ContactType.room ||
|
||||
contact.type == ContactType.repeater;
|
||||
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);
|
||||
|
||||
@@ -381,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(
|
||||
@@ -454,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) {
|
||||
@@ -982,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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
2769
lib/widgets/sensors/sensor_telemetry_card.dart
Normal file
2769
lib/widgets/sensors/sensor_telemetry_card.dart
Normal file
File diff suppressed because it is too large
Load Diff
28
pubspec.lock
28
pubspec.lock
@@ -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: "813f5b3"
|
||||
resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
|
||||
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:
|
||||
|
||||
@@ -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: "813f5b3"
|
||||
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
|
||||
|
||||
|
||||
@@ -137,11 +137,12 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'retains last valid gps for any contact when telemetry gps is invalid or missing',
|
||||
'clears telemetry gps on refresh when gps is invalid or missing but keeps fallback display location',
|
||||
() {
|
||||
final contactTypes = <ContactType>[
|
||||
ContactType.chat,
|
||||
ContactType.repeater,
|
||||
ContactType.sensor,
|
||||
ContactType.room,
|
||||
ContactType.channel,
|
||||
];
|
||||
@@ -164,23 +165,24 @@ void main() {
|
||||
);
|
||||
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), validGps);
|
||||
|
||||
// No GPS frame should keep previous valid GPS.
|
||||
// No GPS frame should clear telemetry GPS but keep the last known
|
||||
// contact location as display fallback.
|
||||
final batteryOnly = CayenneLppParser.createBatteryData(3.8);
|
||||
scopedProvider.updateTelemetry(scopedKey.sublist(0, 6), batteryOnly);
|
||||
|
||||
var updated = scopedProvider.findContactByKey(scopedKey)!;
|
||||
expect(updated.telemetry, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, isNull);
|
||||
expect(
|
||||
updated.telemetry!.gpsLocation!.latitude,
|
||||
updated.displayLocation!.latitude,
|
||||
closeTo(45.1234, 0.0001),
|
||||
);
|
||||
expect(
|
||||
updated.telemetry!.gpsLocation!.longitude,
|
||||
updated.displayLocation!.longitude,
|
||||
closeTo(13.8765, 0.0001),
|
||||
);
|
||||
|
||||
// Invalid 0,0 GPS frame should also keep previous valid GPS.
|
||||
// Invalid 0,0 GPS frame should behave the same way.
|
||||
final invalidGps = CayenneLppParser.createGpsData(
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
@@ -189,13 +191,13 @@ void main() {
|
||||
|
||||
updated = scopedProvider.findContactByKey(scopedKey)!;
|
||||
expect(updated.telemetry, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, isNull);
|
||||
expect(
|
||||
updated.telemetry!.gpsLocation!.latitude,
|
||||
updated.displayLocation!.latitude,
|
||||
closeTo(45.1234, 0.0001),
|
||||
);
|
||||
expect(
|
||||
updated.telemetry!.gpsLocation!.longitude,
|
||||
updated.displayLocation!.longitude,
|
||||
closeTo(13.8765, 0.0001),
|
||||
);
|
||||
}
|
||||
@@ -335,7 +337,7 @@ void main() {
|
||||
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
||||
});
|
||||
|
||||
test('retains prior telemetry fields across sparse telemetry updates', () {
|
||||
test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () {
|
||||
final fullTelemetry = ContactTelemetry(
|
||||
gpsLocation: const LatLng(46.0569, 14.5058),
|
||||
batteryPercentage: 54.0,
|
||||
@@ -359,13 +361,70 @@ void main() {
|
||||
|
||||
final updated = provider.findContactByKey(publicKey)!;
|
||||
expect(updated.telemetry, isNotNull);
|
||||
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058));
|
||||
expect(updated.telemetry!.gpsLocation, isNull);
|
||||
expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
|
||||
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||
expect(updated.telemetry!.batteryPercentage, isNotNull);
|
||||
expect(updated.telemetry!.temperature, equals(19.5));
|
||||
expect(updated.telemetry!.humidity, equals(58.0));
|
||||
expect(updated.telemetry!.pressure, equals(1011.2));
|
||||
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0));
|
||||
expect(
|
||||
updated.telemetry!.extraSensorData,
|
||||
containsPair('__source_channel:battery', 0),
|
||||
);
|
||||
expect(
|
||||
updated.telemetry!.extraSensorData,
|
||||
containsPair('__source_channel:voltage', 0),
|
||||
);
|
||||
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
|
||||
});
|
||||
|
||||
test('replaces old source-channel mappings when a metric moves channels', () {
|
||||
final initialTelemetry = ContactTelemetry(
|
||||
gpsLocation: null,
|
||||
batteryPercentage: null,
|
||||
batteryMilliVolts: null,
|
||||
temperature: 21.5,
|
||||
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
|
||||
humidity: null,
|
||||
pressure: null,
|
||||
extraSensorData: const {
|
||||
'__source_channel:temperature': 2,
|
||||
'temperature_2': 21.5,
|
||||
'humidity_4': 66.0,
|
||||
},
|
||||
);
|
||||
|
||||
provider.addOrUpdateContact(
|
||||
createContact(
|
||||
key: publicKey,
|
||||
type: ContactType.chat,
|
||||
).copyWith(telemetry: initialTelemetry),
|
||||
);
|
||||
|
||||
final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
|
||||
23.5,
|
||||
channel: 3,
|
||||
);
|
||||
|
||||
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry);
|
||||
|
||||
final updated = provider.findContactByKey(publicKey)!;
|
||||
expect(updated.telemetry, isNotNull);
|
||||
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
|
||||
expect(
|
||||
updated.telemetry!.extraSensorData,
|
||||
containsPair('__source_channel:temperature', 3),
|
||||
);
|
||||
expect(
|
||||
updated.telemetry!.extraSensorData,
|
||||
containsPair('temperature_3', closeTo(23.5, 0.1)),
|
||||
);
|
||||
expect(
|
||||
updated.telemetry!.extraSensorData,
|
||||
isNot(contains('temperature_2')),
|
||||
);
|
||||
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4')));
|
||||
});
|
||||
|
||||
test('builds message snapshot from latest valid telemetry', () {
|
||||
|
||||
273
test/providers/sensors_provider_test.dart
Normal file
273
test/providers/sensors_provider_test.dart
Normal 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',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
97
test/screens/sensors_tab_test.dart
Normal file
97
test/screens/sensors_tab_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
25
test/services/notification_service_test.dart
Normal file
25
test/services/notification_service_test.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/services/notification_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('discovery notification preference persists independently', () async {
|
||||
final service = NotificationService();
|
||||
|
||||
await service.setMessageNotificationsEnabled(true);
|
||||
await service.setDiscoveryNotificationsEnabled(false);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
expect(service.messageNotificationsEnabled, isTrue);
|
||||
expect(service.discoveryNotificationsEnabled, isFalse);
|
||||
expect(prefs.getBool('notifications_messages_enabled'), isTrue);
|
||||
expect(prefs.getBool('notifications_discovery_enabled'), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal file
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
91
test/widgets/sensor_metric_selector_item_test.dart
Normal file
91
test/widgets/sensor_metric_selector_item_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
190
test/widgets/sensor_telemetry_card_test.dart
Normal file
190
test/widgets/sensor_telemetry_card_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user