Compare commits

..

8 Commits

Author SHA1 Message Date
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
35 changed files with 4478 additions and 2537 deletions

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',
@@ -1360,6 +1350,7 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_error = null;
await _activeService.addOrUpdateContact(contact);
} catch (e) {
_error = 'Failed to add/update contact: $e';
@@ -1367,6 +1358,22 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<void> importContactAdvert(Uint8List contactAdvertFrame) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
await _activeService.importContact(contactAdvertFrame);
} catch (e) {
_error = 'Failed to import contact: $e';
notifyListeners();
}
}
/// Send text message to contact
///
/// Returns true if the message was successfully sent to the BLE service.
@@ -2578,110 +2585,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 +2592,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(
@@ -1526,6 +1531,7 @@ class ContactsProvider with ChangeNotifier {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'sensor': sensorContacts.length,
'room': rooms.length,
'total': contacts.length,
};

View File

@@ -15,6 +15,10 @@ 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, 1, 5, 15];
static const Set<String> _defaultVisibleFields = <String>{
'voltage',
'battery',
@@ -23,6 +27,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 +44,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 +71,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 +97,47 @@ 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 = (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,6 +178,36 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> _persistMetricLabels() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor));
} catch (e) {
debugPrint('Error saving sensor metric labels: $e');
}
}
Future<void> _persistMetricOrder() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor));
} catch (e) {
debugPrint('Error saving sensor metric order: $e');
}
}
Future<void> _persistAutoRefreshMinutes() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_autoRefreshMinutesKey,
jsonEncode(_autoRefreshMinutesBySensor),
);
} catch (e) {
debugPrint('Error saving sensor auto refresh minutes: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
@@ -131,6 +221,78 @@ 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 = minutes <= 0 ? 0 : minutes;
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
if (currentMinutes == normalizedMinutes) {
return;
}
if (normalizedMinutes == 0) {
_autoRefreshMinutesBySensor.remove(publicKeyHex);
} else {
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
}
await _persistAutoRefreshMinutes();
notifyListeners();
}
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
final refreshTime = now ?? DateTime.now();
final dueKeys = <String>[];
for (final key in _watchedSensorKeys) {
final minutes = autoRefreshMinutesFor(key);
if (minutes <= 0) {
continue;
}
final lastRefreshAt = _lastRefreshAttemptAt[key];
if (lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
dueKeys.add(key);
}
}
return List<String>.unmodifiable(dueKeys);
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
@@ -140,8 +302,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 +320,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners();
}
@@ -166,11 +340,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)) {
@@ -183,8 +408,14 @@ class SensorsProvider with ChangeNotifier {
_defaultVisibleFields,
);
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
_metricOrderBySensor[contact.publicKeyHex] = List<String>.from(
_defaultMetricOrder,
);
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
notifyListeners();
}
@@ -194,9 +425,16 @@ class SensorsProvider with ChangeNotifier {
_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 +442,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 +478,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 +512,38 @@ class SensorsProvider with ChangeNotifier {
);
}
Future<void> refreshDueSensors({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? now,
}) async {
if (!connectionProvider.deviceInfo.isConnected ||
_isRefreshingAll ||
_isRunningAutoRefreshTick) {
return;
}
final refreshTime = now ?? DateTime.now();
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
if (dueKeys.isEmpty) {
return;
}
_isRunningAutoRefreshTick = true;
try {
for (final key in dueKeys) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
requestedAt: refreshTime,
);
}
} finally {
_isRunningAutoRefreshTick = false;
}
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];

View File

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

View File

@@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
@@ -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('Add 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('Add Contact'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: const Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
],
),
),
],
@@ -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) {

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(

View File

@@ -5,6 +5,22 @@ 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 _lppSwitch = 142;
/// Parse Cayenne LPP data into ContactTelemetry
static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...');
@@ -25,7 +41,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 +82,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 +104,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 +117,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 +163,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 +183,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,9 +246,111 @@ 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 _lppSwitch:
final value = reader.readByte();
debugPrint(' Switch: $value');
extraSensorData['switch_$channel'] = value;
break;
default:
debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
@@ -258,6 +412,16 @@ 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 String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey';
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

@@ -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 _showSensorPreviewSheet(context, contact);
},
),
if (canAddToSensors)
ListTile(
leading: Icon(
@@ -454,6 +469,53 @@ class ContactTile extends StatelessWidget {
);
}
Future<void> _showSensorPreviewSheet(
BuildContext context,
Contact contact,
) async {
final publicKeyHex = contact.publicKeyHex;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Consumer2<ContactsProvider, SensorsProvider>(
builder: (context, contactsProvider, sensorsProvider, child) {
Contact? liveContact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
liveContact = entry;
break;
}
}
final previewContact = liveContact ?? contact;
final visibleFields = sensorMetricKeysFor(previewContact);
final fieldOrder = sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
);
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: SensorTelemetryCard(
contact: previewContact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: fieldOrder,
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
),
),
);
},
),
);
}
void _showContactOnMap(BuildContext context, Contact contact) {
final location = contact.displayLocation;
if (location == null) {

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

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

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

View File

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

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

View File

@@ -360,6 +360,78 @@ 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 lppSwitch = 142;
final payload = Uint8List.fromList([
2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300
3, lppCurrent, 0x00, 0x0F, // 0.015 A
4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz
5, lppAltitude, 0x01, 0xF4, // 500 m
6, lppConcentration, 0x01, 0x9F, // 415 ppm
7, lppPower, 0x00, 0xFA, // 250 W
8, lppSpeed, 0x04, 0xD2, // 12.34 m/s
9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m
10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh
11, lppDirection, 0x01, 0x0E, // 270 deg
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
14, lppSwitch, 0x01, // on
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData, isNotNull);
expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0));
expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001));
expect(decoded.extraSensorData!['frequency_4'], equals(1000.0));
expect(decoded.extraSensorData!['altitude_5'], equals(500.0));
expect(decoded.extraSensorData!['concentration_6'], equals(415.0));
expect(decoded.extraSensorData!['power_7'], equals(250.0));
expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001));
expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['direction_11'], equals(270.0));
expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456));
expect(
decoded.extraSensorData!['colour_13'],
equals({'r': 255, 'g': 128, 'b': 64}),
);
expect(decoded.extraSensorData!['switch_14'], equals(1));
});
test(
'percentage battery and non-battery voltage channels are preserved separately',
() {
const lppPercentage = 120;
final payload = Uint8List.fromList([
1, lppPercentage, 66, // battery %
2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V
2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryPercentage, equals(66.0));
expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001));
expect(decoded.temperature, closeTo(23.5, 0.1));
expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1));
},
);
test('unknown sensor type is skipped gracefully', () {
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,79 @@ 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.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));
});
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
testWidgets('renders selector previews and channel badges', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'extra:illuminance_2',
label: 'Illuminance (ch 2)',
defaultLabel: 'Illuminance',
channel: 2,
valuePreview: '500 lx',
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('500 lx'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget,
);
expect(find.text('ch2'), findsOneWidget);
});
}

View File

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