Compare commits

...

21 Commits

Author SHA1 Message Date
Janez T
a55cfffa59 Redesign contact import screen 2026-03-15 21:02:17 +01:00
Janez T
2bb5b353bd Fix channel refresh telemetry how 2026-03-15 21:00:02 +01:00
Janez T
991421d097 Fix telemetry merge bug 2026-03-15 20:58:12 +01:00
Janez Troha
1dcd85f178 Merge pull request #26 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 20:14:31 +01:00
Janez T
5d2f61749e Update sensor parsing layout 2026-03-15 20:14:01 +01:00
Janez T
69377d90a4 Update auto-refresh and UI 2026-03-15 11:15:19 +01:00
Janez T
3e559a4f4e Enforce theme colors on switches 2026-03-15 10:56:47 +01:00
Janez T
a124e3d9f2 Improve sensor settings UI 2026-03-15 10:48:48 +01:00
Janez T
3bf4971279 Update sensor preview interactions 2026-03-15 10:43:37 +01:00
Janez T
e355f28ca9 Update sensor customization view 2026-03-15 10:40:16 +01:00
Janez Troha
7a9be3eb76 Merge pull request #25 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 10:34:36 +01:00
Janez T
650f3c66aa Show values in sensor fields 2026-03-15 10:22:53 +01:00
Janez T
df993cd176 Enhance sensor telemetry cards 2026-03-15 10:10:22 +01:00
Janez T
feb3145854 Add sensor contact preview sheet 2026-03-15 09:36:31 +01:00
Janez T
da40acf950 chore: Update Meshcore client pin
ref:
2026-03-15 09:31:18 +01:00
Janez T
66eb32bf44 Fix BLE contact add failure 2026-03-15 09:24:41 +01:00
Janez T
c2e0fa952e Fix contact add failure 2026-03-15 09:19:59 +01:00
Janez T
4557fb4b9e feat: Add Sensor telemetry alerts
ref:
2026-03-15 09:13:32 +01:00
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34:57 +01:00
41 changed files with 6966 additions and 2814 deletions

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,6 @@ enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default)
ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp,
}
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.tcp:
return 'Direct (WiFi)';
}
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
}

View File

@@ -1,71 +0,0 @@
/// SSE Server Configuration Model
///
/// Configuration for the SSE (Server-Sent Events) web server that enables
/// multiple app instances to share a single MeshCore BLE device.
class SseServerConfig {
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
final String host;
/// Server port (default: 12929)
final int port;
/// Whether the SSE server is enabled
final bool enabled;
/// Optional authentication token for basic security
/// Clients must include this token in Authorization header
final String? authToken;
const SseServerConfig({
this.host = '0.0.0.0',
this.port = 12929,
this.enabled = false,
this.authToken,
});
/// Create a copy with updated fields
SseServerConfig copyWith({
String? host,
int? port,
bool? enabled,
String? authToken,
}) {
return SseServerConfig(
host: host ?? this.host,
port: port ?? this.port,
enabled: enabled ?? this.enabled,
authToken: authToken ?? this.authToken,
);
}
/// Get server URL for clients to connect to
String getServerUrl({String? ipAddress}) {
final ip = ipAddress ?? host;
return 'http://$ip:$port';
}
/// Convert to JSON for persistence
Map<String, dynamic> toJson() {
return {
'host': host,
'port': port,
'enabled': enabled,
'authToken': authToken,
};
}
/// Create from JSON
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
return SseServerConfig(
host: json['host'] as String? ?? '0.0.0.0',
port: json['port'] as int? ?? 12929,
enabled: json['enabled'] as bool? ?? false,
authToken: json['authToken'] as String?,
);
}
@override
String toString() {
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
}
}

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
@@ -15,6 +16,19 @@ class SensorsProvider with ChangeNotifier {
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
static const String _metricLabelKey = 'sensor_metric_labels';
static const String _metricOrderKey = 'sensor_metric_order';
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
static const List<int> supportedAutoRefreshIntervals = <int>[
0,
5,
15,
30,
60,
360,
720,
1440,
];
static const Set<String> _defaultVisibleFields = <String>{
'voltage',
'battery',
@@ -23,6 +37,14 @@ class SensorsProvider with ChangeNotifier {
'pressure',
'gps',
};
static const List<String> _defaultMetricOrder = <String>[
'voltage',
'battery',
'temperature',
'humidity',
'pressure',
'gps',
];
final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates =
@@ -32,8 +54,15 @@ class SensorsProvider with ChangeNotifier {
<String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor =
<String, Map<String, int>>{};
final Map<String, Map<String, String>> _metricLabelsBySensor =
<String, Map<String, String>>{};
final Map<String, List<String>> _metricOrderBySensor =
<String, List<String>>{};
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
bool _isLoaded = false;
bool _isRefreshingAll = false;
bool _isRunningAutoRefreshTick = false;
SensorsProvider() {
unawaited(_loadWatchedSensors());
@@ -52,11 +81,17 @@ class SensorsProvider with ChangeNotifier {
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
final storedSpansJson = prefs.getString(_fieldSpanKey);
final storedLabelsJson = prefs.getString(_metricLabelKey);
final storedOrderJson = prefs.getString(_metricOrderKey);
final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey);
_watchedSensorKeys
..clear()
..addAll(stored);
_visibleFieldsBySensor.clear();
_fieldSpansBySensor.clear();
_metricLabelsBySensor.clear();
_metricOrderBySensor.clear();
_autoRefreshMinutesBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
@@ -72,12 +107,49 @@ class SensorsProvider with ChangeNotifier {
.map((key, value) => MapEntry(key, value as int));
}
}
if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) {
final decoded = jsonDecode(storedLabelsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_metricLabelsBySensor[entry.key] =
(entry.value as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as String),
);
}
}
if (storedOrderJson != null && storedOrderJson.isNotEmpty) {
final decoded = jsonDecode(storedOrderJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_metricOrderBySensor[entry.key] = (entry.value as List<dynamic>)
.cast<String>()
.toList();
}
}
if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) {
final decoded =
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final minutes = _normalizeAutoRefreshMinutes(
(entry.value as num).toInt(),
);
if (minutes > 0) {
_autoRefreshMinutesBySensor[entry.key] = minutes;
}
}
}
_autoRefreshMinutesBySensor.removeWhere(
(key, _) => !_watchedSensorKeys.contains(key),
);
for (final key in _watchedSensorKeys) {
_visibleFieldsBySensor.putIfAbsent(
key,
() => Set<String>.from(_defaultVisibleFields),
);
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
_metricOrderBySensor.putIfAbsent(
key,
() => List<String>.from(_defaultMetricOrder),
);
}
} catch (e) {
debugPrint('Error loading watched sensors: $e');
@@ -118,10 +190,56 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> _persistMetricLabels() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor));
} catch (e) {
debugPrint('Error saving sensor metric labels: $e');
}
}
Future<void> _persistMetricOrder() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor));
} catch (e) {
debugPrint('Error saving sensor metric order: $e');
}
}
Future<void> _persistAutoRefreshMinutes() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_autoRefreshMinutesKey,
jsonEncode(_autoRefreshMinutesBySensor),
);
} catch (e) {
debugPrint('Error saving sensor auto refresh minutes: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
Set<String> effectiveVisibleFieldsFor(
String publicKeyHex,
Iterable<String> availableFieldKeys,
) {
final storedVisibleFields =
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
return Set<String>.unmodifiable(storedVisibleFields);
}
return Set<String>.unmodifiable(<String>{
...storedVisibleFields,
...availableFieldKeys,
});
}
bool showsField(String publicKeyHex, String fieldKey) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey);
@@ -131,6 +249,104 @@ class SensorsProvider with ChangeNotifier {
return span == 2 ? 2 : 1;
}
List<String> metricOrderFor(
String publicKeyHex,
Iterable<String> availableFieldKeys,
) {
final available = availableFieldKeys.toList();
final availableSet = available.toSet();
final ordered = <String>[];
final seen = <String>{};
final stored =
_metricOrderBySensor[publicKeyHex] ??
List<String>.from(_defaultMetricOrder);
for (final fieldKey in stored) {
if (availableSet.contains(fieldKey) && seen.add(fieldKey)) {
ordered.add(fieldKey);
}
}
for (final fieldKey in available) {
if (seen.add(fieldKey)) {
ordered.add(fieldKey);
}
}
return List<String>.unmodifiable(ordered);
}
Map<String, String> labelOverridesFor(String publicKeyHex) =>
Map<String, String>.unmodifiable(
_metricLabelsBySensor[publicKeyHex] ?? const <String, String>{},
);
String? labelOverrideFor(String publicKeyHex, String fieldKey) =>
_metricLabelsBySensor[publicKeyHex]?[fieldKey];
int autoRefreshMinutesFor(String publicKeyHex) =>
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
if (currentMinutes == normalizedMinutes) {
return;
}
if (normalizedMinutes == 0) {
_autoRefreshMinutesBySensor.remove(publicKeyHex);
} else {
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
}
await _persistAutoRefreshMinutes();
notifyListeners();
}
static int _normalizeAutoRefreshMinutes(int minutes) {
if (minutes <= 0) {
return 0;
}
if (supportedAutoRefreshIntervals.contains(minutes)) {
return minutes;
}
final positiveIntervals = supportedAutoRefreshIntervals.where(
(value) => value > 0,
);
var bestMatch = positiveIntervals.first;
var bestDistance = (minutes - bestMatch).abs();
for (final interval in positiveIntervals.skip(1)) {
final distance = (minutes - interval).abs();
if (distance < bestDistance) {
bestMatch = interval;
bestDistance = distance;
}
}
return bestMatch;
}
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
final refreshTime = now ?? DateTime.now();
final dueKeys = <String>[];
for (final key in _watchedSensorKeys) {
final minutes = autoRefreshMinutesFor(key);
if (minutes <= 0) {
continue;
}
final lastRefreshAt = _lastRefreshAttemptAt[key];
if (lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
dueKeys.add(key);
}
}
return List<String>.unmodifiable(dueKeys);
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
@@ -140,8 +356,17 @@ class SensorsProvider with ChangeNotifier {
publicKeyHex,
() => Set<String>.from(_defaultVisibleFields),
);
final metricOrder = _metricOrderBySensor.putIfAbsent(
publicKeyHex,
() => List<String>.from(_defaultMetricOrder),
);
var shouldPersistOrder = false;
if (visible) {
visibleFields.add(fieldKey);
if (!metricOrder.contains(fieldKey)) {
metricOrder.add(fieldKey);
shouldPersistOrder = true;
}
} else {
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
return;
@@ -149,6 +374,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners();
}
@@ -166,11 +394,62 @@ class SensorsProvider with ChangeNotifier {
notifyListeners();
}
Future<void> setMetricLabel(
String publicKeyHex,
String fieldKey,
String? label,
) async {
final sensorLabels = _metricLabelsBySensor.putIfAbsent(
publicKeyHex,
() => <String, String>{},
);
final trimmed = label?.trim();
if (trimmed == null || trimmed.isEmpty) {
sensorLabels.remove(fieldKey);
} else {
sensorLabels[fieldKey] = trimmed;
}
if (sensorLabels.isEmpty) {
_metricLabelsBySensor.remove(publicKeyHex);
}
await _persistMetricLabels();
notifyListeners();
}
Future<void> moveMetric(
String publicKeyHex, {
required List<String> availableFieldKeys,
required int oldIndex,
required int newIndex,
}) async {
if (oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= availableFieldKeys.length ||
newIndex >= availableFieldKeys.length ||
oldIndex == newIndex) {
return;
}
final reordered = List<String>.from(
metricOrderFor(publicKeyHex, availableFieldKeys),
);
final fieldKey = reordered.removeAt(oldIndex);
reordered.insert(newIndex, fieldKey);
final storedTail =
(_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where(
(key) => !reordered.contains(key),
);
_metricOrderBySensor[publicKeyHex] = <String>[...reordered, ...storedTail];
await _persistMetricOrder();
notifyListeners();
}
bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex);
Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater) {
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return;
}
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
@@ -178,25 +457,69 @@ class SensorsProvider with ChangeNotifier {
}
_watchedSensorKeys.add(contact.publicKeyHex);
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
await _persistWatchedSensors();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
_defaultVisibleFields,
);
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
contact.publicKeyHex,
initialVisibleFields,
);
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
notifyListeners();
}
bool _shouldAutoIncludeAvailableFields(
String publicKeyHex,
Set<String> storedVisibleFields,
) {
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
return false;
}
final storedOrder = _metricOrderBySensor[publicKeyHex];
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
return false;
}
final storedLabels = _metricLabelsBySensor[publicKeyHex];
if (storedLabels != null && storedLabels.isNotEmpty) {
return false;
}
final storedSpans = _fieldSpansBySensor[publicKeyHex];
if (storedSpans != null &&
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
return false;
}
return true;
}
Set<String> _initialVisibleFieldsForContact(Contact contact) {
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
}
Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex);
_metricLabelsBySensor.remove(publicKeyHex);
_metricOrderBySensor.remove(publicKeyHex);
_autoRefreshMinutesBySensor.remove(publicKeyHex);
_lastRefreshAttemptAt.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
await _persistAutoRefreshMinutes();
notifyListeners();
}
@@ -204,6 +527,7 @@ class SensorsProvider with ChangeNotifier {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
...contactsProvider.sensorContacts,
];
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
@@ -239,7 +563,14 @@ class SensorsProvider with ChangeNotifier {
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? requestedAt,
}) async {
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
return;
}
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
@@ -266,6 +597,38 @@ class SensorsProvider with ChangeNotifier {
);
}
Future<void> refreshDueSensors({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? now,
}) async {
if (!connectionProvider.deviceInfo.isConnected ||
_isRefreshingAll ||
_isRunningAutoRefreshTick) {
return;
}
final refreshTime = now ?? DateTime.now();
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
if (dueKeys.isEmpty) {
return;
}
_isRunningAutoRefreshTick = true;
try {
for (final key in dueKeys) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
requestedAt: refreshTime,
);
}
} finally {
_isRunningAutoRefreshTick = false;
}
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key});
@override
State<AddContactScreen> createState() => _AddContactScreenState();
}
class _AddContactScreenState extends State<AddContactScreen> {
final TextEditingController _advertController = TextEditingController();
bool _isImporting = false;
bool _importSucceeded = false;
String? _validationError;
@override
void initState() {
super.initState();
_loadClipboardIfPresent();
}
@override
void dispose() {
_advertController.dispose();
super.dispose();
}
Future<void> _loadClipboardIfPresent() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted || text == null || text.isEmpty) {
return;
}
if (_normalizeAdvertText(text) == null) {
return;
}
_advertController.text = text;
}
String? _normalizeAdvertText(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
var normalized = trimmed;
if (normalized.startsWith('meshcore://')) {
normalized = normalized.substring('meshcore://'.length);
}
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) {
return null;
}
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
if (!isHex || normalized.length.isOdd) {
return null;
}
return normalized.toLowerCase();
}
Uint8List _hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
}
return Uint8List.fromList(bytes);
}
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
return;
}
setState(() {
_advertController.text = text.trim();
_importSucceeded = false;
_validationError = null;
});
}
Future<void> _importContact() async {
final normalized = _normalizeAdvertText(_advertController.text);
if (normalized == null) {
setState(() {
_validationError =
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
});
return;
}
final advertBytes = _hexToBytes(normalized);
if (advertBytes.length < 98) {
setState(() {
_validationError =
'Advert is too short. Expected exported contact data.';
});
return;
}
setState(() {
_isImporting = true;
_importSucceeded = false;
_validationError = null;
});
final connectionProvider = context.read<ConnectionProvider>();
await connectionProvider.importContactAdvert(advertBytes);
final importError = connectionProvider.error;
if (importError == null) {
await connectionProvider.getContacts();
}
if (!mounted) {
return;
}
setState(() {
_isImporting = false;
});
if (importError != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(importError)));
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Contact imported')));
setState(() {
_importSucceeded = true;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final normalized = _normalizeAdvertText(_advertController.text);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Import Contact')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.person_add_alt_1_outlined,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Import a shared contact advert',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 14),
Text(
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_ImportHintChip(
icon: Icons.link_outlined,
label: 'Accepts share links',
),
_ImportHintChip(
icon: Icons.code_outlined,
label: 'Supports raw hex',
),
_ImportHintChip(
icon: Icons.content_paste_go_outlined,
label: 'Clipboard-friendly',
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert payload',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'You can paste the full share link or only the hex payload.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
TextField(
controller: _advertController,
minLines: 5,
maxLines: 9,
onChanged: (_) {
if (_validationError != null || _importSucceeded) {
setState(() {
_importSucceeded = false;
_validationError = null;
});
}
},
decoration: InputDecoration(
labelText: 'Contact advert',
hintText: 'Paste a share link or hex advert',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
errorText: _validationError,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
normalized == null
? 'No valid advert detected yet'
: 'Advert size: ${normalized.length ~/ 2} bytes',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: _isImporting ? null : _pasteFromClipboard,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Paste'),
),
],
),
],
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: (_isImporting || _importSucceeded)
? null
: _importContact,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
icon: _isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _importSucceeded
? const Icon(Icons.check_circle_outline)
: const Icon(Icons.person_add_alt_1_outlined),
label: Text(
_isImporting
? 'Importing...'
: _importSucceeded
? 'Imported'
: 'Import Contact',
),
),
const SizedBox(height: 12),
if (_importSucceeded)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.primary.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Contact imported. Paste another advert or edit the current one to import again.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
class _ImportHintChip extends StatelessWidget {
final IconData icon;
final String label;
const _ImportHintChip({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.labelMedium),
],
),
);
}
}

View File

@@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/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;

View File

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

View File

@@ -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) {

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
@@ -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

View File

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

View File

@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
/// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices
class CayenneLppParser {
static const int _selfTelemetryChannel = 1;
static const int _lppGenericSensor = 100;
static const int _lppCurrent = 117;
static const int _lppFrequency = 118;
static const int _lppPercentage = 120;
static const int _lppAltitude = 121;
static const int _lppConcentration = 125;
static const int _lppPower = 128;
static const int _lppSpeed = 129;
static const int _lppDistance = 130;
static const int _lppEnergy = 131;
static const int _lppDirection = 132;
static const int _lppUnixTime = 133;
static const int _lppColour = 135;
static const int _lppGust = 137;
static const int _lppDewPoint = 138;
static const int _lppRain = 139;
static const int _lppSwitch = 142;
static const int _lppBinaryBool = 143;
static const int _lppBinaryPowerSwitch = 144;
static const int _lppBinaryOpen = 145;
static const int _lppBinaryBatteryLow = 146;
static const int _lppBinaryCharging = 147;
static const int _lppBinaryCarbonMonoxide = 148;
static const int _lppBinaryCold = 149;
static const int _lppBinaryConnectivity = 150;
static const int _lppBinaryDoor = 151;
static const int _lppBinaryGarageDoor = 152;
static const int _lppBinaryGas = 153;
static const int _lppBinaryHeat = 154;
static const int _lppBinaryLight = 155;
static const int _lppBinaryLock = 156;
static const int _lppBinaryMoisture = 157;
static const int _lppBinaryMotion = 158;
static const int _lppBinaryMoving = 159;
static const int _lppBinaryOccupancy = 160;
static const int _lppBinaryPlug = 161;
static const int _lppBinaryPresence = 162;
static const int _lppBinaryProblem = 163;
static const int _lppBinaryRunning = 164;
static const int _lppBinarySafety = 165;
static const int _lppBinarySmoke = 166;
static const int _lppBinarySound = 167;
static const int _lppBinaryTamper = 168;
static const int _lppBinaryVibration = 169;
static const int _lppBinaryWindow = 170;
static const int _lppButtonEvent = 171;
static const int _lppDimmer = 172;
static const int _lppUv = 173;
static const int _lppLightLevel = 174;
static const int _lppPm25 = 175;
static const int _lppPm10 = 176;
static const int _lppCo2 = 177;
static const int _lppTvoc = 178;
static const int _lppRpm = 179;
static const int _lppConductivity = 180;
static const int _lppRotation = 181;
static const int _lppDuration = 182;
static const int _lppAcceleration = 183;
static const int _lppGyroRate = 184;
static const int _lppVolume = 185;
static const int _lppFlowRate = 186;
static const int _lppVolumeStorage = 187;
static const int _lppWater = 188;
static const int _lppGasVolume = 189;
static const int _lppMass = 190;
static const int _lppSignedSpeed = 191;
static const int _lppSignedPower = 192;
static const int _lppSignedCurrent = 193;
/// Parse Cayenne LPP data into ContactTelemetry
static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...');
@@ -25,7 +95,8 @@ class CayenneLppParser {
int fieldCount = 0;
while (reader.hasRemaining) {
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
if (fieldCount > 0 &&
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
debugPrint(
' Detected zero-padded telemetry tail, stopping parse at position '
'${data.length - reader.remainingBytesCount}',
@@ -65,14 +136,16 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V');
extraSensorData['analog_input_$channel'] = value;
// If this is a battery reading
if (channel == 0 || channel == 1) {
if (_isBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else {
extraSensorData['analog_input_$channel'] = value;
}
break;
@@ -85,7 +158,7 @@ class CayenneLppParser {
break;
case MeshCoreConstants.lppIlluminanceSensor:
final value = reader.readUInt16BE();
final value = reader.readUInt16BE().toDouble();
debugPrint(' Illuminance: $value lux');
extraSensorData['illuminance_$channel'] = value;
break;
@@ -98,18 +171,36 @@ class CayenneLppParser {
case MeshCoreConstants.lppTemperatureSensor:
final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0;
final value = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue');
debugPrint(
' Temperature: ${temperature.toStringAsFixed(1)}°C',
);
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
if (channel == _selfTelemetryChannel) {
temperature = value;
extraSensorData[_sourceChannelKey('temperature')] = channel;
} else {
extraSensorData['temperature_$channel'] = value;
if (temperature == null) {
temperature = value;
extraSensorData[_sourceChannelKey('temperature')] = channel;
}
}
break;
case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte();
humidity = rawValue / 2.0;
final value = rawValue / 2.0;
debugPrint(' Humidity (raw): $rawValue');
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
if (channel == _selfTelemetryChannel) {
humidity = value;
extraSensorData[_sourceChannelKey('humidity')] = channel;
} else {
extraSensorData['humidity_$channel'] = value;
if (humidity == null) {
humidity = value;
extraSensorData[_sourceChannelKey('humidity')] = channel;
}
}
break;
case MeshCoreConstants.lppAccelerometer:
@@ -126,9 +217,19 @@ class CayenneLppParser {
case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0;
final value = rawValue / 10.0;
debugPrint(' Barometer (raw): $rawValue');
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
if (channel == _selfTelemetryChannel) {
pressure = value;
extraSensorData[_sourceChannelKey('pressure')] = channel;
} else {
extraSensorData['pressure_$channel'] = value;
if (pressure == null) {
pressure = value;
extraSensorData[_sourceChannelKey('pressure')] = channel;
}
}
break;
case MeshCoreConstants.lppVoltageSensor:
@@ -136,12 +237,17 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V');
// Treat voltage sensor as battery reading
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
if (_isBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else {
extraSensorData['voltage_$channel'] = value;
}
break;
case MeshCoreConstants.lppGyrometer:
@@ -194,15 +300,328 @@ class CayenneLppParser {
}
gpsLocation = LatLng(lat, lon);
extraSensorData[_sourceChannelKey('gps')] = channel;
extraSensorData['altitude_$channel'] = alt;
break;
case _lppGenericSensor:
final value = _readUInt32BE(reader).toDouble();
debugPrint(' Generic Sensor: $value');
extraSensorData['generic_sensor_$channel'] = value;
break;
case _lppCurrent:
final rawValue = reader.readInt16BE();
final value = rawValue / 1000.0;
debugPrint(' Current (raw): $rawValue');
debugPrint(' Current: ${value}A');
extraSensorData['current_$channel'] = value;
break;
case _lppFrequency:
final value = _readUInt32BE(reader).toDouble();
debugPrint(' Frequency: ${value}Hz');
extraSensorData['frequency_$channel'] = value;
break;
case _lppPercentage:
final value = reader.readByte().toDouble();
debugPrint(' Percentage: $value%');
if (_isBatteryChannel(channel)) {
batteryPercentage = value;
extraSensorData[_sourceChannelKey('battery')] = channel;
} else {
extraSensorData['percentage_$channel'] = value;
}
break;
case _lppAltitude:
final rawValue = reader.readInt16BE();
final value = rawValue.toDouble();
debugPrint(' Altitude: ${value}m');
extraSensorData['altitude_$channel'] = value;
break;
case _lppConcentration:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Concentration: ${value}ppm');
extraSensorData['concentration_$channel'] = value;
break;
case _lppPower:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Power: ${value}W');
extraSensorData['power_$channel'] = value;
break;
case _lppSpeed:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Speed: ${value}m/s');
extraSensorData['speed_$channel'] = value;
break;
case _lppDistance:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Distance: ${value}m');
extraSensorData['distance_$channel'] = value;
break;
case _lppEnergy:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Energy: ${value}kWh');
extraSensorData['energy_$channel'] = value;
break;
case _lppDirection:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Direction: $value°');
extraSensorData['direction_$channel'] = value;
break;
case _lppUnixTime:
final value = _readUInt32BE(reader);
debugPrint(' Unix time: $value');
extraSensorData['unixtime_$channel'] = value;
break;
case _lppColour:
final red = reader.readByte();
final green = reader.readByte();
final blue = reader.readByte();
debugPrint(' Colour: r=$red, g=$green, b=$blue');
extraSensorData['colour_$channel'] = {
'r': red,
'g': green,
'b': blue,
};
break;
case _lppGust:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Gust: ${value}m/s');
extraSensorData['gust_$channel'] = value;
break;
case _lppDewPoint:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
extraSensorData['dew_$channel'] = value;
break;
case _lppRain:
final rawValue = reader.readUInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rain: ${value}mm');
extraSensorData['rain_$channel'] = value;
break;
case _lppBinaryBool:
case _lppBinaryPowerSwitch:
case _lppBinaryOpen:
case _lppBinaryBatteryLow:
case _lppBinaryCharging:
case _lppBinaryCarbonMonoxide:
case _lppBinaryCold:
case _lppBinaryConnectivity:
case _lppBinaryDoor:
case _lppBinaryGarageDoor:
case _lppBinaryGas:
case _lppBinaryHeat:
case _lppBinaryLight:
case _lppBinaryLock:
case _lppBinaryMoisture:
case _lppBinaryMotion:
case _lppBinaryMoving:
case _lppBinaryOccupancy:
case _lppBinaryPlug:
case _lppBinaryPresence:
case _lppBinaryProblem:
case _lppBinaryRunning:
case _lppBinarySafety:
case _lppBinarySmoke:
case _lppBinarySound:
case _lppBinaryTamper:
case _lppBinaryVibration:
case _lppBinaryWindow:
final value = reader.readByte();
debugPrint(' Binary state: $value');
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
value;
break;
case _lppButtonEvent:
final value = reader.readByte();
debugPrint(' Button event: $value');
extraSensorData['button_event_$channel'] = value;
break;
case _lppDimmer:
final value = _readInt8(reader);
debugPrint(' Dimmer: $value');
extraSensorData['dimmer_$channel'] = value;
break;
case _lppUv:
final value = reader.readByte() / 10.0;
debugPrint(' UV index: $value');
extraSensorData['uv_$channel'] = value;
break;
case _lppLightLevel:
final value = reader.readByte();
debugPrint(' Light level: $value');
extraSensorData['light_level_$channel'] = value;
break;
case _lppPm25:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM2.5: $value');
extraSensorData['pm25_$channel'] = value;
break;
case _lppPm10:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM10: $value');
extraSensorData['pm10_$channel'] = value;
break;
case _lppCo2:
final value = reader.readUInt16BE().toDouble();
debugPrint(' CO2: $value');
extraSensorData['co2_$channel'] = value;
break;
case _lppTvoc:
final value = reader.readUInt16BE().toDouble();
debugPrint(' TVOC: $value');
extraSensorData['tvoc_$channel'] = value;
break;
case _lppRpm:
final value = reader.readUInt16BE().toDouble();
debugPrint(' RPM: $value');
extraSensorData['rpm_$channel'] = value;
break;
case _lppConductivity:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Conductivity: $value');
extraSensorData['conductivity_$channel'] = value;
break;
case _lppRotation:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rotation: $value');
extraSensorData['rotation_$channel'] = value;
break;
case _lppDuration:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Duration: $value s');
extraSensorData['duration_$channel'] = value;
break;
case _lppAcceleration:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Acceleration: $value');
extraSensorData['acceleration_$channel'] = value;
break;
case _lppGyroRate:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gyro rate: $value');
extraSensorData['gyro_rate_$channel'] = value;
break;
case _lppVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Volume: $value');
extraSensorData['volume_$channel'] = value;
break;
case _lppFlowRate:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Flow rate: $value');
extraSensorData['flow_rate_$channel'] = value;
break;
case _lppVolumeStorage:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Storage volume: $value');
extraSensorData['volume_storage_$channel'] = value;
break;
case _lppWater:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Water: $value');
extraSensorData['water_$channel'] = value;
break;
case _lppGasVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gas volume: $value');
extraSensorData['gas_volume_$channel'] = value;
break;
case _lppMass:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Mass: $value');
extraSensorData['mass_$channel'] = value;
break;
case _lppSignedSpeed:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Signed speed: $value');
extraSensorData['signed_speed_$channel'] = value;
break;
case _lppSignedPower:
final rawValue = _readInt32BE(reader);
final value = rawValue / 100.0;
debugPrint(' Signed power: $value');
extraSensorData['signed_power_$channel'] = value;
break;
case _lppSignedCurrent:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Signed current: $value');
extraSensorData['signed_current_$channel'] = value;
break;
case _lppSwitch:
final value = reader.readByte();
debugPrint(' Switch: $value');
extraSensorData['switch_$channel'] = value;
break;
default:
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
);
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount);
final size = _payloadSizeForType(type);
if (size == null || reader.remainingBytesCount < size) {
debugPrint(
' ⚠️ Unknown type $type with unsupported size, stopping parse',
);
reader.skip(reader.remainingBytesCount);
break;
}
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
reader.skip(size);
break;
}
} catch (e) {
@@ -258,6 +677,163 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0;
}
static bool _isBatteryChannel(int channel) =>
channel == 0 || channel == _selfTelemetryChannel;
static int _readUInt32BE(BufferReader reader) {
final bytes = reader.readBytes(4);
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
}
static int _readInt32BE(BufferReader reader) {
final value = _readUInt32BE(reader);
if ((value & 0x80000000) != 0) {
return value - 0x100000000;
}
return value;
}
static int _readInt8(BufferReader reader) {
final value = reader.readByte();
if ((value & 0x80) != 0) {
return value - 0x100;
}
return value;
}
static String _sourceChannelKey(String fieldKey) =>
'__source_channel:$fieldKey';
static String _binaryMetricKeyForType(int type) {
switch (type) {
case _lppBinaryBool:
return 'binary_bool';
case _lppBinaryPowerSwitch:
return 'binary_power_switch';
case _lppBinaryOpen:
return 'binary_open';
case _lppBinaryBatteryLow:
return 'binary_battery_low';
case _lppBinaryCharging:
return 'binary_charging';
case _lppBinaryCarbonMonoxide:
return 'binary_carbon_monoxide';
case _lppBinaryCold:
return 'binary_cold';
case _lppBinaryConnectivity:
return 'binary_connectivity';
case _lppBinaryDoor:
return 'binary_door';
case _lppBinaryGarageDoor:
return 'binary_garage_door';
case _lppBinaryGas:
return 'binary_gas';
case _lppBinaryHeat:
return 'binary_heat';
case _lppBinaryLight:
return 'binary_light';
case _lppBinaryLock:
return 'binary_lock';
case _lppBinaryMoisture:
return 'binary_moisture';
case _lppBinaryMotion:
return 'binary_motion';
case _lppBinaryMoving:
return 'binary_moving';
case _lppBinaryOccupancy:
return 'binary_occupancy';
case _lppBinaryPlug:
return 'binary_plug';
case _lppBinaryPresence:
return 'binary_presence';
case _lppBinaryProblem:
return 'binary_problem';
case _lppBinaryRunning:
return 'binary_running';
case _lppBinarySafety:
return 'binary_safety';
case _lppBinarySmoke:
return 'binary_smoke';
case _lppBinarySound:
return 'binary_sound';
case _lppBinaryTamper:
return 'binary_tamper';
case _lppBinaryVibration:
return 'binary_vibration';
case _lppBinaryWindow:
return 'binary_window';
}
return 'binary_state';
}
static int? _payloadSizeForType(int type) {
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
return 1;
}
switch (type) {
case MeshCoreConstants.lppDigitalInput:
case MeshCoreConstants.lppDigitalOutput:
case MeshCoreConstants.lppPresenceSensor:
case MeshCoreConstants.lppHumiditySensor:
case _lppPercentage:
case _lppSwitch:
case _lppButtonEvent:
case _lppDimmer:
case _lppUv:
case _lppLightLevel:
return 1;
case MeshCoreConstants.lppAnalogInput:
case MeshCoreConstants.lppAnalogOutput:
case MeshCoreConstants.lppIlluminanceSensor:
case MeshCoreConstants.lppTemperatureSensor:
case MeshCoreConstants.lppBarometer:
case MeshCoreConstants.lppVoltageSensor:
case _lppCurrent:
case _lppAltitude:
case _lppConcentration:
case _lppPower:
case _lppSpeed:
case _lppDirection:
case _lppGust:
case _lppDewPoint:
case _lppRain:
case _lppPm25:
case _lppPm10:
case _lppCo2:
case _lppTvoc:
case _lppRpm:
case _lppConductivity:
case _lppRotation:
return 2;
case MeshCoreConstants.lppAccelerometer:
case MeshCoreConstants.lppGyrometer:
return 6;
case MeshCoreConstants.lppGps:
return 9;
case _lppGenericSensor:
case _lppFrequency:
case _lppDistance:
case _lppEnergy:
case _lppUnixTime:
case _lppDuration:
case _lppAcceleration:
case _lppGyroRate:
case _lppVolume:
case _lppFlowRate:
case _lppVolumeStorage:
case _lppWater:
case _lppGasVolume:
case _lppMass:
case _lppSignedSpeed:
case _lppSignedPower:
case _lppSignedCurrent:
return 4;
case _lppColour:
return 3;
}
return null;
}
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
final start = data.length - remainingBytes;
for (int i = start; i < data.length; i++) {

View File

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

View File

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

View File

@@ -1,785 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'package:nsd/nsd.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/sse_server_config.dart';
import 'network_scanner_service.dart';
/// SSE Server Service
///
/// Provides a web server with SSE (Server-Sent Events) endpoints for
/// real-time message and contact updates, enabling multiple app instances
/// to share a single MeshCore BLE device.
///
/// Endpoints:
/// - GET /sse/messages - SSE stream for message updates
/// - GET /sse/contacts - SSE stream for contact updates
/// - POST /api/messages - Send message
/// - POST /api/messages/channel - Send channel message
/// - POST /api/contacts/sync - Trigger contact sync
/// - GET /api/messages/history - Get all messages
/// - GET /api/contacts - Get all contacts
/// - GET /api/status - Server health check
class SseServerService {
HttpServer? _server;
SseServerConfig? _config;
Registration? _bonjourRegistration;
/// Active SSE connections for messages
final Set<StreamController<String>> _messageStreams = {};
/// Active SSE connections for contacts
final Set<StreamController<String>> _contactStreams = {};
/// Message history (for new clients)
final List<Message> _messageHistory = [];
/// Contact list (for new clients)
final Map<String, Contact> _contacts = {};
/// Timer for cleaning up dead connections
Timer? _cleanupTimer;
/// Device name (for status endpoint)
String? _deviceName;
/// Set device name
void setDeviceName(String? name) {
_deviceName = name;
debugPrint('📝 [SseServer] Device name set to: $name');
}
/// Callback for when a client requests to send a message
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
/// Callback for when a client requests to send a channel message
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
/// Callback for when a client requests contact sync
Future<void> Function()? onSyncContacts;
/// Check if server is running
bool get isRunning => _server != null;
/// Get current configuration
SseServerConfig? get config => _config;
/// Get number of connected clients
int get connectedClients => _messageStreams.length;
/// CORS middleware
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'Origin, Content-Type, Authorization',
},
);
},
);
}
/// Start the SSE server
Future<void> startServer(SseServerConfig config) async {
if (_server != null) {
debugPrint('⚠️ [SseServer] Server already running');
return;
}
_config = config;
try {
debugPrint(
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
);
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
.addMiddleware(_corsHeaders)
.addMiddleware(shelf.logRequests())
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(handler, config.host, config.port);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
// Start cleanup timer for dead connections
_startCleanupTimer();
// Register Bonjour/mDNS service
await _registerBonjourService(config);
} catch (e) {
debugPrint('❌ [SseServer] Failed to start server: $e');
_server = null;
rethrow;
}
}
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint(
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
);
_bonjourRegistration = await register(
const Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: 0, // Will be set dynamically
),
);
// Update with actual port
if (_bonjourRegistration != null) {
// Unregister and re-register with correct port
await unregister(_bonjourRegistration!);
_bonjourRegistration = await register(
Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: config.port,
),
);
debugPrint(
'✅ [SseServer] Bonjour service registered on port ${config.port}',
);
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
// Don't throw - server can still work without Bonjour
}
}
/// Start cleanup timer to remove dead connections
void _startCleanupTimer() {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
_cleanupDeadConnections();
});
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
}
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
);
debugPrint(
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
);
}
}
/// Stop the SSE server
Future<void> stopServer() async {
if (_server == null) {
return;
}
debugPrint('🛑 [SseServer] Stopping server...');
// Stop cleanup timer
_cleanupTimer?.cancel();
_cleanupTimer = null;
// Close all SSE streams
for (final stream in _messageStreams) {
await stream.close();
}
_messageStreams.clear();
for (final stream in _contactStreams) {
await stream.close();
}
_contactStreams.clear();
// Unregister Bonjour service
if (_bonjourRegistration != null) {
try {
await unregister(_bonjourRegistration!);
debugPrint('✅ [SseServer] Bonjour service unregistered');
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
}
_bonjourRegistration = null;
}
// Close HTTP server
await _server!.close(force: true);
_server = null;
_config = null;
debugPrint('✅ [SseServer] Server stopped');
}
/// Main request handler
Future<shelf.Response> _handleRequest(shelf.Request request) async {
// Check authentication if token is configured
if (_config?.authToken != null) {
final authHeader = request.headers['authorization'];
if (authHeader != 'Bearer ${_config!.authToken}') {
return shelf.Response.forbidden('Invalid authentication token');
}
}
final path = request.url.path;
final method = request.method;
debugPrint('📨 [SseServer] $method /$path');
// Route requests
if (method == 'GET' && path == 'sse/messages') {
return _handleSseMessages(request);
} else if (method == 'GET' && path == 'sse/contacts') {
return _handleSseContacts(request);
} else if (method == 'POST' && path == 'api/messages') {
return _handlePostMessage(request);
} else if (method == 'POST' && path == 'api/messages/channel') {
return _handlePostChannelMessage(request);
} else if (method == 'POST' && path == 'api/contacts/sync') {
return _handlePostContactsSync(request);
} else if (method == 'GET' && path == 'api/messages/history') {
return _handleGetMessageHistory(request);
} else if (method == 'GET' && path == 'api/contacts') {
return _handleGetContacts(request);
} else if (method == 'GET' && path == 'api/status') {
return _handleGetStatus(request);
} else if (method == 'GET' && path == '') {
return _handleRoot(request);
}
return shelf.Response.notFound('Not found');
}
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint(
'📥 [SseServer] New SSE client connected (messages) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_messageStreams.add(controller);
debugPrint(' Total clients: ${_messageStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial message history
for (final message in _messageHistory) {
final event = _formatSseEvent('message', _messageToJson(message));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_messageStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
debugPrint(' Total clients: ${_messageStreams.length}');
});
}
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint(
'📥 [SseServer] New SSE client connected (contacts) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_contactStreams.add(controller);
debugPrint(' Total clients: ${_contactStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial contact list
for (final contact in _contacts.values) {
final event = _formatSseEvent('contact', _contactToJson(contact));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_contactStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
debugPrint(' Total clients: ${_contactStreams.length}');
});
}
/// Handle POST message request
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final recipientPublicKey = json['recipientPublicKey'] as String;
final text = json['text'] as String;
if (onSendMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send message callback not configured'}),
);
}
final success = await onSendMessage!(recipientPublicKey, text);
return shelf.Response.ok(
jsonEncode({'success': success}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(
shelf.Request request,
) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final channelIdx = json['channelIdx'] as int;
final text = json['text'] as String;
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({
'error': 'Send channel message callback not configured',
}),
);
}
await onSendChannelMessage!(channelIdx, text);
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST contacts sync request
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
try {
if (onSyncContacts == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
);
}
await onSyncContacts!();
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle GET message history request
shelf.Response _handleGetMessageHistory(shelf.Request request) {
final messages = _messageHistory.map(_messageToJson).toList();
return shelf.Response.ok(
jsonEncode({'messages': messages}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET contacts request
shelf.Response _handleGetContacts(shelf.Request request) {
final contacts = _contacts.values.map(_contactToJson).toList();
return shelf.Response.ok(
jsonEncode({'contacts': contacts}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET status request
shelf.Response _handleGetStatus(shelf.Request request) {
return shelf.Response.ok(
jsonEncode({
'status': 'running',
'connectedClients': connectedClients,
'messageCount': _messageHistory.length,
'contactCount': _contacts.length,
'deviceName': _deviceName,
}),
headers: {'content-type': 'application/json'},
);
}
/// Handle root request (landing page)
shelf.Response _handleRoot(shelf.Request request) {
final html = '''
<!DOCTYPE html>
<html>
<head>
<title>MeshCore SAR - SSE Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; }
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 MeshCore SAR Server</h1>
<div class="status">✅ Server is running</div>
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
<h2>📡 SSE Endpoints</h2>
<div class="endpoint">GET /sse/messages</div>
<div class="endpoint">GET /sse/contacts</div>
<h2>🔧 API Endpoints</h2>
<div class="endpoint">POST /api/messages</div>
<div class="endpoint">POST /api/messages/channel</div>
<div class="endpoint">POST /api/contacts/sync</div>
<div class="endpoint">GET /api/messages/history</div>
<div class="endpoint">GET /api/contacts</div>
<div class="endpoint">GET /api/status</div>
<h2>📊 Stats</h2>
<p>Connected clients: <strong id="clients">Loading...</strong></p>
<p>Messages: <strong id="messages">Loading...</strong></p>
<p>Contacts: <strong id="contacts">Loading...</strong></p>
</div>
<script>
async function updateStats() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('clients').textContent = data.connectedClients;
document.getElementById('messages').textContent = data.messageCount;
document.getElementById('contacts').textContent = data.contactCount;
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>
''';
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
}
/// Broadcast a new message to all SSE clients
void broadcastMessage(Message message) {
// Add to history (limit to 1000 messages)
_messageHistory.add(message);
if (_messageHistory.length > 1000) {
_messageHistory.removeAt(0);
}
// Broadcast to all connected clients
final event = _formatSseEvent('message', _messageToJson(message));
final deadStreams = <StreamController<String>>[];
for (final stream in _messageStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
);
}
debugPrint(
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
);
}
/// Broadcast a new or updated contact to all SSE clients
void broadcastContact(Contact contact) {
// Update contact list
_contacts[contact.publicKeyHex] = contact;
// Broadcast to all connected clients
final event = _formatSseEvent('contact', _contactToJson(contact));
final deadStreams = <StreamController<String>>[];
for (final stream in _contactStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
);
}
debugPrint(
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
);
}
/// Format SSE event
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
return 'event: $eventType\ndata: $jsonData\n\n';
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsCoordinates': message.sarGpsCoordinates != null
? {
'latitude': message.sarGpsCoordinates!.latitude,
'longitude': message.sarGpsCoordinates!.longitude,
}
: null,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
'receivedAt': message.receivedAt.toIso8601String(),
'senderName': message.senderName,
'deliveryStatus': message.deliveryStatus.name,
'expectedAckTag': message.expectedAckTag,
'suggestedTimeoutMs': message.suggestedTimeoutMs,
'roundTripTimeMs': message.roundTripTimeMs,
'deliveredAt': message.deliveredAt?.toIso8601String(),
'recipientPublicKey': message.recipientPublicKey?.toList(),
'retryAttempt': message.retryAttempt,
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
'usedFloodFallback': message.usedFloodFallback,
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};
}
/// Convert Contact to JSON
Map<String, dynamic> _contactToJson(Contact contact) {
return {
'publicKey': contact.publicKey.toList(),
'publicKeyHex': contact.publicKeyHex,
'type': contact.type.value,
'flags': contact.flags,
'outPathLen': contact.outPathLen,
'outPath': contact.outPath.toList(),
'advName': contact.advName,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? {
'batteryPercentage': contact.telemetry!.batteryPercentage,
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
'temperature': contact.telemetry!.temperature,
'humidity': contact.telemetry!.humidity,
'pressure': contact.telemetry!.pressure,
'gpsLocation': contact.telemetry!.gpsLocation != null
? {
'latitude': contact.telemetry!.gpsLocation!.latitude,
'longitude': contact.telemetry!.gpsLocation!.longitude,
}
: null,
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
}
: null,
};
}
/// Clear message history
void clearMessageHistory() {
_messageHistory.clear();
}
/// Clear contact list
void clearContacts() {
_contacts.clear();
}
}

View File

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

View File

@@ -108,6 +108,8 @@ class ContactAvatar extends StatelessWidget {
return Colors.orange;
case ContactType.room:
return Colors.purple;
case ContactType.sensor:
return Colors.green;
case ContactType.channel:
return Colors.teal;
}
@@ -130,6 +132,8 @@ class ContactAvatar extends StatelessWidget {
return Icons.router;
case ContactType.room:
return Icons.meeting_room;
case ContactType.sensor:
return Icons.sensors;
case ContactType.channel:
return Icons.public;
}

View File

@@ -1,160 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
import '../models/sse_server_config.dart';
/// Connection Mode Selector Widget
///
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
class ConnectionModeSelector extends StatefulWidget {
const ConnectionModeSelector({super.key});
@override
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
}
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
List<String> _localIPs = [];
@override
void initState() {
super.initState();
_loadLocalIPs();
}
Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {};
try {
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
ipsSet.add(addr.address);
}
}
}
} catch (e) {
debugPrint('Error getting network interfaces: $e');
}
if (mounted) {
setState(() {
_localIPs = ipsSet.toList();
});
}
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final connectionProvider = Provider.of<ConnectionProvider>(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Network Sharing',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
// SSE Server Toggle
SwitchListTile(
secondary: const Icon(Icons.share),
title: const Text('Share Device (Server)'),
subtitle: Text(
connectionProvider.isSseServerRunning
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
: 'Share BLE device with multiple clients over network',
),
value: connectionProvider.isSseServerRunning,
onChanged: (enabled) async {
if (enabled) {
// Start server with default config (port 12929, no auth)
final config = const SseServerConfig(port: 12929, enabled: true);
try {
await connectionProvider.startSseServer(config);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('SSE server started on port 12929'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start server: $e'),
backgroundColor: Colors.red,
),
);
}
}
} else {
// Stop server
await connectionProvider.stopSseServer();
}
},
),
// Show IP addresses when server is running
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Connect from other devices:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
..._localIPs.map((ip) {
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
return ListTile(
dense: true,
leading: const Icon(Icons.wifi, size: 20),
title: Text(
url,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 20),
tooltip: 'Copy URL',
onPressed: () {
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied $url'),
duration: const Duration(seconds: 1),
),
);
},
),
);
}),
],
],
);
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -631,14 +631,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_parser:
dependency: transitive
description:
@@ -795,8 +787,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "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:

View File

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

View File

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

View File

@@ -0,0 +1,273 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/device_info.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeContactsProvider extends ContactsProvider {
_FakeContactsProvider(this._contacts);
final List<Contact> _contacts;
@override
List<Contact> get contacts => _contacts;
}
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
final bool _isConnected;
int pingCalls = 0;
@override
DeviceInfo get deviceInfo => DeviceInfo(
connectionState: _isConnected
? ConnectionState.connected
: ConnectionState.disconnected,
);
@override
Future<PingResult> smartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
pingCalls += 1;
return const PingResult(
success: true,
usedFlooding: false,
timedOut: false,
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<void> waitUntilLoaded(SensorsProvider provider) async {
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(provider.isLoaded, isTrue);
}
Contact buildSensorContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact buildTelemetrySensorContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x55;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Weather Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
temperature: 11.8,
humidity: 51,
pressure: 921.9,
timestamp: DateTime.now(),
extraSensorData: const {
'__source_channel:temperature': 3,
'__source_channel:humidity': 2,
'__source_channel:pressure': 2,
'illuminance_2': 19150.0,
'temperature_2': 1.99,
'voltage_2': 5.2,
'switch_2': 0,
'speed_2': 2.8,
'speed_3': 3.7,
'uv_2': 1.0,
},
),
);
}
test('metric label overrides persist across reloads', () async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.setMetricLabel(
contact.publicKeyHex,
'extra:illuminance_2',
'Solar',
);
expect(
provider.labelOverrideFor(contact.publicKeyHex, 'extra:illuminance_2'),
'Solar',
);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(
reloadedProvider.labelOverrideFor(
contact.publicKeyHex,
'extra:illuminance_2',
),
'Solar',
);
});
test('metric order persists across reloads', () async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.moveMetric(
contact.publicKeyHex,
availableFieldKeys: const ['voltage', 'battery', 'temperature'],
oldIndex: 2,
newIndex: 0,
);
expect(
provider.metricOrderFor(contact.publicKeyHex, const [
'voltage',
'battery',
'temperature',
]),
const ['temperature', 'voltage', 'battery'],
);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(
reloadedProvider.metricOrderFor(contact.publicKeyHex, const [
'voltage',
'battery',
'temperature',
]),
const ['temperature', 'voltage', 'battery'],
);
});
test('auto refresh minutes persist across reloads', () async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1440);
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
});
test(
'unsupported auto refresh minutes normalize to nearest option',
() async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1);
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
},
);
test('refreshDueSensors respects per-contact interval', () async {
SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact();
final contactsProvider = _FakeContactsProvider(<Contact>[contact]);
final connectionProvider = _FakeConnectionProvider(isConnected: true);
final start = DateTime(2026, 3, 15, 9, 0);
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5);
await provider.refreshDueSensors(
now: start,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 1);
await provider.refreshDueSensors(
now: start.add(const Duration(minutes: 4)),
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 1);
await provider.refreshDueSensors(
now: start.add(const Duration(minutes: 5)),
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
expect(connectionProvider.pingCalls, 2);
});
test(
'addSensor includes available extra telemetry fields by default',
() async {
SharedPreferences.setMockInitialValues({});
final contact = buildTelemetrySensorContact();
final provider = SensorsProvider();
await waitUntilLoaded(provider);
await provider.addSensor(contact);
expect(
provider.visibleFieldsFor(contact.publicKeyHex),
containsAll(<String>{
'temperature',
'humidity',
'pressure',
'extra:illuminance_2',
'extra:temperature_2',
'extra:voltage_2',
'extra:switch_2',
'extra:speed_2',
'extra:speed_3',
'extra:uv_2',
}),
);
},
);
}

View File

@@ -57,6 +57,25 @@ void main() {
);
}
Contact buildSensor({required int seed, required String name}) {
final publicKey = Uint8List(32);
publicKey[0] = seed;
publicKey[1] = seed + 1;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46056000 + seed,
advLon: 14505000 + seed,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpContactsTab(
WidgetTester tester, {
List<Contact> contacts = const [],
@@ -192,4 +211,14 @@ void main() {
expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget);
});
testWidgets('sensor contacts render in their own section', (tester) async {
await pumpContactsTab(
tester,
contacts: [buildSensor(seed: 60, name: 'WX Station')],
);
expect(find.text('Sensors'), findsOneWidget);
expect(find.text('WX Station'), findsOneWidget);
});
}

View File

@@ -0,0 +1,97 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> waitUntilLoaded(SensorsProvider provider) async {
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
await Future<void>.delayed(Duration.zero);
}
expect(provider.isLoaded, isTrue);
}
Contact buildSensorContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
}
testWidgets('customize action opens full customization view', (tester) async {
final contact = buildSensorContact();
final sensorsProvider = SensorsProvider();
final contactsProvider = ContactsProvider();
await waitUntilLoaded(sensorsProvider);
contactsProvider.addOrUpdateContact(contact);
await sensorsProvider.addSensor(contact);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ContactsProvider>.value(
value: contactsProvider,
),
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SensorsTab(),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.text('Customize fields'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('Customize WX Station'), findsOneWidget);
expect(find.text('Live preview'), findsOneWidget);
expect(find.text('Refresh schedule'), findsOneWidget);
expect(
find.byKey(
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
}

View File

@@ -360,6 +360,134 @@ void main() {
expect(accel['z'], closeTo(2.0, 0.001));
});
test('extended MeshCore LPP types decode to structured extra data', () {
const lppGenericSensor = 100;
const lppCurrent = 117;
const lppFrequency = 118;
const lppAltitude = 121;
const lppConcentration = 125;
const lppPower = 128;
const lppSpeed = 129;
const lppDistance = 130;
const lppEnergy = 131;
const lppDirection = 132;
const lppUnixTime = 133;
const lppColour = 135;
const lppGust = 137;
const lppDewPoint = 138;
const lppRain = 139;
const lppSwitch = 142;
final payload = Uint8List.fromList([
2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300
3, lppCurrent, 0x00, 0x0F, // 0.015 A
4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz
5, lppAltitude, 0x01, 0xF4, // 500 m
6, lppConcentration, 0x01, 0x9F, // 415 ppm
7, lppPower, 0x00, 0xFA, // 250 W
8, lppSpeed, 0x04, 0xD2, // 12.34 m/s
9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m
10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh
11, lppDirection, 0x01, 0x0E, // 270 deg
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
15, lppGust, 0x01, 0x72, // 3.70 m/s
16, lppDewPoint, 0x00, 0x14, // 2.0 C
17, lppRain, 0x00, 0x7B, // 12.3 mm
14, lppSwitch, 0x01, // on
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData, isNotNull);
expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0));
expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001));
expect(decoded.extraSensorData!['frequency_4'], equals(1000.0));
expect(decoded.extraSensorData!['altitude_5'], equals(500.0));
expect(decoded.extraSensorData!['concentration_6'], equals(415.0));
expect(decoded.extraSensorData!['power_7'], equals(250.0));
expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001));
expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['direction_11'], equals(270.0));
expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456));
expect(
decoded.extraSensorData!['colour_13'],
equals({'r': 255, 'g': 128, 'b': 64}),
);
expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001));
expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001));
expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001));
expect(decoded.extraSensorData!['switch_14'], equals(1));
});
test('new MeshCore custom telemetry types decode without truncation', () {
const lppBinaryMoisture = 157;
const lppButtonEvent = 171;
const lppDimmer = 172;
const lppUv = 173;
const lppPm25 = 175;
const lppConductivity = 180;
const lppDuration = 182;
const lppSignedSpeed = 191;
const lppSignedPower = 192;
const lppSignedCurrent = 193;
final payload = Uint8List.fromList([
2, lppBinaryMoisture, 0x01,
3, lppButtonEvent, 0x04,
4, lppDimmer, 0xFB, // -5
5, lppUv, 0x0A, // 1.0
6, lppPm25, 0x00, 0x0C, // 12
7, lppConductivity, 0x01, 0xF4, // 500
8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s
9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s
10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W
11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A
12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData!['binary_moisture_2'], equals(1));
expect(decoded.extraSensorData!['button_event_3'], equals(4));
expect(decoded.extraSensorData!['dimmer_4'], equals(-5));
expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001));
expect(decoded.extraSensorData!['pm25_6'], equals(12.0));
expect(decoded.extraSensorData!['conductivity_7'], equals(500.0));
expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001));
expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001));
expect(
decoded.extraSensorData!['signed_power_10'],
closeTo(-10.0, 0.001),
);
expect(
decoded.extraSensorData!['signed_current_11'],
closeTo(-1.0, 0.001),
);
expect(decoded.temperature, closeTo(11.8, 0.1));
});
test(
'percentage battery and non-battery voltage channels are preserved separately',
() {
const lppPercentage = 120;
final payload = Uint8List.fromList([
1, lppPercentage, 66, // battery %
2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V
2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryPercentage, equals(66.0));
expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001));
expect(decoded.temperature, closeTo(23.5, 0.1));
expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1));
},
);
test('unknown sensor type is skipped gracefully', () {
final buffer = ByteData(5);
buffer.setUint8(0, 0);

View File

@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/notification_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('discovery notification preference persists independently', () async {
final service = NotificationService();
await service.setMessageNotificationsEnabled(true);
await service.setDiscoveryNotificationsEnabled(false);
final prefs = await SharedPreferences.getInstance();
expect(service.messageNotificationsEnabled, isTrue);
expect(service.discoveryNotificationsEnabled, isFalse);
expect(prefs.getBool('notifications_messages_enabled'), isTrue);
expect(prefs.getBool('notifications_discovery_enabled'), isFalse);
});
}

View File

@@ -31,7 +31,9 @@ void main() {
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
home: Scaffold(
body: Center(child: ContactAvatar(contact: contact)),
),
),
);
}
@@ -85,7 +87,11 @@ void main() {
testWidgets('renders non-hash label avatar for channels', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
buildContact(
name: 'Command Net',
type: ContactType.channel,
secondByte: 3,
),
);
expect(find.text('CN'), findsOneWidget);
@@ -103,4 +109,15 @@ void main() {
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byIcon(Icons.person), findsNothing);
});
testWidgets('renders sensor icon avatar for sensor contacts', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byIcon(Icons.sensors), findsOneWidget);
expect(find.text('WS'), findsNothing);
});
}

View File

@@ -10,6 +10,7 @@ import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -40,14 +41,21 @@ void main() {
);
}
Future<void> pumpTile(WidgetTester tester, Contact contact) async {
Future<void> pumpTile(
WidgetTester tester,
Contact contact, {
SensorsProvider? sensorsProvider,
}) async {
final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider();
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider<SensorsProvider>.value(
value: resolvedSensorsProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
@@ -104,4 +112,82 @@ void main() {
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
});
testWidgets('sensor contacts can be added to sensors', (tester) async {
await pumpTile(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Add to Sensors'), findsOneWidget);
});
testWidgets('sensor preview shows telemetry card', (tester) async {
final contact = buildContact(name: 'WX Station', type: ContactType.sensor)
.copyWith(
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
humidity: 58.0,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'__source_channel:humidity': 1,
'co2': 415.0,
'illuminance_2': 500.0,
'current_2': 0.015,
'power_2': 0.25,
'distance_2': 1.234,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
await pumpTile(tester, contact);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Preview'), findsOneWidget);
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2 daylight'), findsOneWidget);
expect(find.text('Current'), findsOneWidget);
expect(find.text('15 mA'), findsOneWidget);
expect(find.text('Power'), findsOneWidget);
expect(find.text('Distance'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_metric_channel_battery')),
findsOneWidget,
);
expect(find.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
findsOneWidget,
);
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
});
}

View File

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

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
testWidgets('renders selector previews and channel badges', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'extra:illuminance_2',
label: 'Illuminance (ch 2)',
defaultLabel: 'Illuminance',
channel: 2,
valuePreview: '500 lx',
previewCardData: SensorMetricCardData(
fieldKey: 'extra:illuminance_2',
icon: Icons.light_mode_outlined,
label: 'Illuminance',
value: '500 lx',
accent: Color(0xFFC17B1D),
channel: 2,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Show on sensor card'), findsOneWidget);
expect(find.text('500 lx'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
testWidgets('omits duplicate channel chip for channel 1', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'battery',
label: 'Battery',
defaultLabel: 'Battery',
channel: 1,
valuePreview: '84%',
previewCardData: SensorMetricCardData(
fieldKey: 'battery',
icon: Icons.battery_5_bar,
label: 'Battery',
value: '84%',
accent: Color(0xFF4B8E2F),
channel: 1,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Rename'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_battery')),
findsNothing,
);
expect(find.text('Channel 1'), findsNothing);
});
}

View File

@@ -0,0 +1,190 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
Contact buildContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
temperature: 21.5,
extraSensorData: const {
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
}
testWidgets('renders custom labels and channel badges', (tester) async {
final contact = buildContact();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature', 'extra:illuminance_2'},
labelOverrides: const {
'temperature': 'Ambient',
'extra:illuminance_2': 'Light',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'temperature',
'extra:illuminance_2',
}),
),
),
),
);
expect(find.text('Ambient'), findsOneWidget);
expect(find.text('Light'), findsOneWidget);
expect(find.text('Temperature'), findsNothing);
expect(find.text('Illuminance'), findsNothing);
expect(
find.byKey(const ValueKey('sensor_metric_channel_temperature')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
findsOneWidget,
);
});
testWidgets('renders metrics in the provided order', (tester) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x45;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'battery',
'temperature',
'extra:illuminance_2',
},
fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'],
fieldSpans: sensorFullWidthFieldSpans(const {
'battery',
'temperature',
'extra:illuminance_2',
}),
),
),
),
);
final illuminanceTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
);
final temperatureTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_temperature')),
);
final batteryTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(illuminanceTop.dy, lessThan(temperatureTop.dy));
expect(temperatureTop.dy, lessThan(batteryTop.dy));
});
testWidgets('renders MeshCore custom weather metrics', (tester) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x46;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
}),
),
),
),
);
expect(find.text('Wind gust'), findsOneWidget);
expect(find.text('Dew point'), findsOneWidget);
expect(find.text('Rain'), findsOneWidget);
expect(find.text('3.7 m/s'), findsOneWidget);
expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), findsOneWidget);
});
}