From c2e0fa952e507fe1da9077bc1ca6ffe677f7fa96 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 15 Mar 2026 09:19:59 +0100 Subject: [PATCH] Fix contact add failure --- lib/models/device_info.dart | 7 - lib/models/sse_server_config.dart | 71 -- lib/providers/app_provider.dart | 69 +- lib/providers/connection_provider.dart | 130 +-- lib/screens/discovery_screen.dart | 443 +++++----- lib/screens/settings_screen.dart | 8 +- lib/services/map_marker_service.dart | 196 +++-- lib/services/sse_server_service.dart | 785 ------------------ lib/widgets/connection_mode_selector.dart | 160 ---- lib/widgets/contacts/room_login_sheet.dart | 4 + .../map/compass/compass_contact_list.dart | 137 +-- lib/widgets/map_markers.dart | 232 +++--- pubspec.lock | 24 - pubspec.yaml | 4 - 14 files changed, 608 insertions(+), 1662 deletions(-) delete mode 100644 lib/models/sse_server_config.dart delete mode 100644 lib/services/sse_server_service.dart delete mode 100644 lib/widgets/connection_mode_selector.dart diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index 22b59fe..4a8c63c 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -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'; } diff --git a/lib/models/sse_server_config.dart b/lib/models/sse_server_config.dart deleted file mode 100644 index 6eb1452..0000000 --- a/lib/models/sse_server_config.dart +++ /dev/null @@ -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 toJson() { - return { - 'host': host, - 'port': port, - 'enabled': enabled, - 'authToken': authToken, - }; - } - - /// Create from JSON - factory SseServerConfig.fromJson(Map 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})'; - } -} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 296d864..f6a8587 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -948,7 +948,7 @@ class AppProvider with ChangeNotifier { contactName: contact.advName.trim().isEmpty ? null : contact.advName, - ), + ), ); } if (contact.type == ContactType.repeater && @@ -966,9 +966,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 +979,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 +1204,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 +1244,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } @@ -1289,7 +1277,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } @@ -1314,9 +1301,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 +1337,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 +1367,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 +1656,9 @@ class AppProvider with ChangeNotifier { unawaited( _notificationService.showContactDiscoveredNotification( contactKey: keyHex, - contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName, + contactName: contactsProvider + .pendingAdvertByKey(publicKey) + ?.advName, ), ); } @@ -2248,10 +2237,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 +2339,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; } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 30e80bb..6b330a6 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -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'; @@ -2578,110 +2569,6 @@ class ConnectionProvider with ChangeNotifier { return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix); } - // ============================================================================ - // SSE Server Methods - // ============================================================================ - - /// Start SSE server to share BLE device with multiple clients - Future 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 = []; - 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 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 +2576,6 @@ class ConnectionProvider with ChangeNotifier { _stopAckCleanupTimer(); _bleService.dispose(); _tcpService?.dispose(); - _sseServer.stopServer(); super.dispose(); } } diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index d6cda68..eaa3478 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -125,15 +125,29 @@ class _DiscoveryScreenState extends State { final connectionProvider = context.read(); final contactsProvider = context.read(); - 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 { ); } + bool _canAddPendingAdvertDirectly(PendingAdvert advert) { + return advert.publicKey.length == 32 && + advert.typeValue != null && + advert.typeValue != 0; + } + + Future _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 { ), body: FutureBuilder>( future: _cachedNodesFuture, - builder: (context, nodesSnapshot) => - Consumer2( - builder: (context, contactsProvider, connectionProvider, child) { - final pendingAdverts = contactsProvider.pendingAdverts; - final isConnected = connectionProvider.deviceInfo.isConnected; - final cachedNodes = nodesSnapshot.data ?? const []; + builder: (context, nodesSnapshot) => Consumer2( + builder: (context, contactsProvider, connectionProvider, child) { + final pendingAdverts = contactsProvider.pendingAdverts; + final isConnected = connectionProvider.deviceInfo.isConnected; + final cachedNodes = nodesSnapshot.data ?? const []; - 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 = [ - '${l10n.publicKey}: ${advert.shortDisplayKey}', - ]; - final summaryParts = []; - 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 = [ + '${l10n.publicKey}: ${advert.shortDisplayKey}', + ]; + final summaryParts = []; + 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 { 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), ), ], ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cc0ec40..e690dd0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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'; @@ -1053,7 +1052,6 @@ class _SettingsScreenState extends State { await NotificationService().setSarNotificationsEnabled(value); }, ), - SwitchListTile( SwitchListTile( secondary: const Icon(Icons.contact_page_outlined), title: const Text('Discovery notifications'), @@ -1070,6 +1068,7 @@ class _SettingsScreenState extends State { ); }, ), + SwitchListTile( secondary: const Icon(Icons.system_update), title: const Text('Update notifications'), subtitle: const Text( @@ -1554,11 +1553,6 @@ class _SettingsScreenState extends State { ), ]), - if (!kIsWeb) ...[ - _buildSectionHeader('Network Sharing'), - const ConnectionModeSelector(), - ], - _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSettingsCard([ SwitchListTile( diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 5ea3f0c..7101110 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -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().toList(); + ); + }) + .whereType() + .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; diff --git a/lib/services/sse_server_service.dart b/lib/services/sse_server_service.dart deleted file mode 100644 index 6837d46..0000000 --- a/lib/services/sse_server_service.dart +++ /dev/null @@ -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> _messageStreams = {}; - - /// Active SSE connections for contacts - final Set> _contactStreams = {}; - - /// Message history (for new clients) - final List _messageHistory = []; - - /// Contact list (for new clients) - final Map _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 Function(String recipientPublicKey, String text)? onSendMessage; - - /// Callback for when a client requests to send a channel message - Future Function(int channelIdx, String text)? onSendChannelMessage; - - /// Callback for when a client requests contact sync - Future 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 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 _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 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 _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(); - _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(); - _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 _handlePostMessage(shelf.Request request) async { - try { - final body = await request.readAsString(); - final json = jsonDecode(body) as Map; - - 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 _handlePostChannelMessage( - shelf.Request request, - ) async { - try { - final body = await request.readAsString(); - final json = jsonDecode(body) as Map; - - 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 _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 = ''' - - - - MeshCore SAR - SSE Server - - - - -
-

🚀 MeshCore SAR Server

-
✅ Server is running
-

This server enables multiple MeshCore SAR clients to share a single BLE device.

- -

📡 SSE Endpoints

-
GET /sse/messages
-
GET /sse/contacts
- -

🔧 API Endpoints

-
POST /api/messages
-
POST /api/messages/channel
-
POST /api/contacts/sync
-
GET /api/messages/history
-
GET /api/contacts
-
GET /api/status
- -

📊 Stats

-

Connected clients: Loading...

-

Messages: Loading...

-

Contacts: Loading...

-
- - - - -'''; - 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 = >[]; - - 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 = >[]; - - 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 data) { - final jsonData = jsonEncode(data); - return 'event: $eventType\ndata: $jsonData\n\n'; - } - - /// Convert Message to JSON - Map _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 _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(); - } -} diff --git a/lib/widgets/connection_mode_selector.dart b/lib/widgets/connection_mode_selector.dart deleted file mode 100644 index 6d61f6e..0000000 --- a/lib/widgets/connection_mode_selector.dart +++ /dev/null @@ -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 createState() => _ConnectionModeSelectorState(); -} - -class _ConnectionModeSelectorState extends State { - List _localIPs = []; - - @override - void initState() { - super.initState(); - _loadLocalIPs(); - } - - Future _loadLocalIPs() async { - if (kIsWeb) { - return; - } - - final Set 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(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), - ), - ); - }, - ), - ); - }), - ], - ], - ); - } -} diff --git a/lib/widgets/contacts/room_login_sheet.dart b/lib/widgets/contacts/room_login_sheet.dart index 3145d90..9f1a80a 100644 --- a/lib/widgets/contacts/room_login_sheet.dart +++ b/lib/widgets/contacts/room_login_sheet.dart @@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State { 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', diff --git a/lib/widgets/map/compass/compass_contact_list.dart b/lib/widgets/map/compass/compass_contact_list.dart index 1af2a5f..51bc4ba 100644 --- a/lib/widgets/map/compass/compass_contact_list.dart +++ b/lib/widgets/map/compass/compass_contact_list.dart @@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget { // Split contacts by type final persons = >[]; final repeaters = >[]; + final sensors = >[]; final rooms = >[]; // 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; diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index 8baa87e..f00de1e 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -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().toList(); + ); + }) + .whereType() + .toList(); } static List 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)), ], ), ); diff --git a/pubspec.lock b/pubspec.lock index 3d666ed..75f3edb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -631,14 +631,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - http_methods: - dependency: transitive - description: - name: http_methods - sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" - url: "https://pub.dev" - source: hosted - version: "1.1.1" http_parser: dependency: transitive description: @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index a21ee0a..de6129b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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