diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ad40376..b794a90 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,8 @@ "Bash(do)", "Bash(wc:*)", "Bash(for lang in de es fr it hr sl)", - "Bash(do echo \"=== $lang ===\")" + "Bash(do echo \"=== $lang ===\")", + "Bash(flutter --version:*)" ], "deny": [], "ask": [] diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml index 11e9e58..77b11e5 100644 --- a/.github/workflows/build-multiplatform.yml +++ b/.github/workflows/build-multiplatform.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: env: - FLUTTER_VERSION: '3.24.0' + FLUTTER_VERSION: '3.35.6' jobs: # Update version from tag diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip index 7785940..7928ee5 100644 Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ diff --git a/ios/Runner.ipa b/ios/Runner.ipa index 2e7fddc..d4e4dcd 100644 Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 2f28a8a..a7d4615 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/models/message.dart b/lib/models/message.dart index 51bb8ee..1c4e209 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -29,11 +29,11 @@ enum MessageType { /// Message delivery status enum MessageDeliveryStatus { - sending, // Message is being sent - sent, // Message queued with expected ACK - delivered, // Delivery confirmed (ACK received) - failed, // Delivery failed - received, // Message received from another contact + sending, // Message is being sent + sent, // Message queued with expected ACK + delivered, // Delivery confirmed (ACK received) + failed, // Delivery failed + received, // Message received from another contact } /// MeshCore message model @@ -64,12 +64,14 @@ class Message { final int? suggestedTimeoutMs; // Suggested timeout from SENT response final int? roundTripTimeMs; // RTT from SEND_CONFIRMED final DateTime? deliveredAt; // When delivery was confirmed - final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry) + final Uint8List? + recipientPublicKey; // Full 32-byte public key of recipient (for retry) // Retry tracking (for automatic retry with progressive timeouts) final int retryAttempt; // Current retry attempt (0-3), 0 = first send final DateTime? lastRetryAt; // When last retry was sent - final bool usedFloodFallback; // Whether message fell back to flood mode after retries + final bool + usedFloodFallback; // Whether message fell back to flood mode after retries // Read status tracking final bool isRead; // Whether message has been read by user @@ -176,10 +178,10 @@ class Message { // Debug: Check what's in sarNotes debugPrint('📍 [Message.toSarMarker] Converting to marker:'); - debugPrint(' message.text: "${text}"'); - debugPrint(' message.sarNotes: "${sarNotes}"'); - debugPrint(' message.sarMarkerType: ${sarMarkerType}'); - debugPrint(' message.sarCustomEmoji: "${sarCustomEmoji}"'); + debugPrint(' message.text: "$text"'); + debugPrint(' message.sarNotes: "$sarNotes"'); + debugPrint(' message.sarMarkerType: $sarMarkerType'); + debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"'); return SarMarker( id: id, @@ -263,11 +265,11 @@ class Message { // Compare sender public key prefix with self public key prefix if (senderPublicKeyPrefix != null && senderPublicKeyPrefix!.length >= 6) { return senderPublicKeyPrefix![0] == selfPublicKey[0] && - senderPublicKeyPrefix![1] == selfPublicKey[1] && - senderPublicKeyPrefix![2] == selfPublicKey[2] && - senderPublicKeyPrefix![3] == selfPublicKey[3] && - senderPublicKeyPrefix![4] == selfPublicKey[4] && - senderPublicKeyPrefix![5] == selfPublicKey[5]; + senderPublicKeyPrefix![1] == selfPublicKey[1] && + senderPublicKeyPrefix![2] == selfPublicKey[2] && + senderPublicKeyPrefix![3] == selfPublicKey[3] && + senderPublicKeyPrefix![4] == selfPublicKey[4] && + senderPublicKeyPrefix![5] == selfPublicKey[5]; } return false; @@ -305,7 +307,8 @@ class Message { return Message( id: id ?? this.id, messageType: messageType ?? this.messageType, - senderPublicKeyPrefix: senderPublicKeyPrefix ?? this.senderPublicKeyPrefix, + senderPublicKeyPrefix: + senderPublicKeyPrefix ?? this.senderPublicKeyPrefix, channelIdx: channelIdx ?? this.channelIdx, pathLen: pathLen ?? this.pathLen, textType: textType ?? this.textType, diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 8687b59..38423d0 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -62,7 +62,7 @@ class ConnectionProvider with ChangeNotifier { DeviceInfo _deviceInfo = DeviceInfo(); DeviceInfo get deviceInfo => _deviceInfo; - List _scannedDevices = []; + final List _scannedDevices = []; List get scannedDevices => _scannedDevices; bool _isScanning = false; @@ -162,14 +162,18 @@ class ConnectionProvider with ChangeNotifier { debugPrint( ' Updated deviceInfo.connectionState: ${_deviceInfo.connectionState}', ); - debugPrint(' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}'); + debugPrint( + ' Updated deviceInfo.isConnected: ${_deviceInfo.isConnected}', + ); debugPrint(' isReconnecting: ${_bleService.isReconnecting}'); notifyListeners(); debugPrint(' Notified listeners'); }; _bleService.onReconnectionAttempt = (attemptNumber, maxAttempts) { - debugPrint('🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts'); + debugPrint( + '🔄 [Provider] Reconnection attempt $attemptNumber/$maxAttempts', + ); // Notify UI to update reconnection status display notifyListeners(); }; @@ -198,7 +202,9 @@ class ConnectionProvider with ChangeNotifier { }; _bleService.onContactNotFound = (contactPublicKey) async { - debugPrint('🔧 [Provider] Contact not found error detected - initiating auto-recovery'); + debugPrint( + '🔧 [Provider] Contact not found error detected - initiating auto-recovery', + ); if (contactPublicKey == null) { debugPrint(' âš ī¸ No contact public key available for recovery'); @@ -206,15 +212,22 @@ class ConnectionProvider with ChangeNotifier { } // Generate operation ID from public key - final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); final pendingOp = _pendingSendOperations[operationId]; if (pendingOp == null || pendingOp.contact == null) { - debugPrint(' âš ī¸ No pending operation found for recovery: $operationId'); + debugPrint( + ' âš ī¸ No pending operation found for recovery: $operationId', + ); return; } - debugPrint(' 📋 Found pending operation for: ${pendingOp.contact!.advName}'); + debugPrint( + ' 📋 Found pending operation for: ${pendingOp.contact!.advName}', + ); debugPrint(' 📤 Step 1: Adding contact to radio...'); try { @@ -266,7 +279,8 @@ class ConnectionProvider with ChangeNotifier { onMessageReceived?.call(enhancedMessage); // Complete sync response completer (message received = continue syncing) - if (_syncResponseCompleter != null && !_syncResponseCompleter!.isCompleted) { + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { _syncResponseCompleter!.complete(true); } }; @@ -295,7 +309,8 @@ class ConnectionProvider with ChangeNotifier { _noMoreMessages = true; // Complete sync response completer (no more messages = stop syncing) - if (_syncResponseCompleter != null && !_syncResponseCompleter!.isCompleted) { + if (_syncResponseCompleter != null && + !_syncResponseCompleter!.isCompleted) { _syncResponseCompleter!.complete(false); } }; @@ -397,7 +412,8 @@ class ConnectionProvider with ChangeNotifier { onMessageDelivered?.call(ackCode, roundTripTimeMs); }; - _bleService.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { + _bleService + .onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { debugPrint( '🔊 [Provider] Echo detected - Message: $messageId, Count: $echoCount', ); @@ -588,7 +604,9 @@ class ConnectionProvider with ChangeNotifier { /// Connect to a device Future connect(BluetoothDevice device) async { - debugPrint('đŸ”ĩ [Provider] connect() called for device: ${device.platformName}'); + debugPrint( + 'đŸ”ĩ [Provider] connect() called for device: ${device.platformName}', + ); _deviceInfo = _deviceInfo.copyWith( deviceId: device.remoteId.toString(), @@ -690,8 +708,12 @@ class ConnectionProvider with ChangeNotifier { } try { - debugPrint('đŸ“ģ [Provider] Configuring default public channel (channel 0)'); - debugPrint(' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); + debugPrint( + 'đŸ“ģ [Provider] Configuring default public channel (channel 0)', + ); + debugPrint( + ' Using secret: ${MeshCoreConstants.defaultPublicChannelSecret.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}', + ); await _bleService.setChannel( channelIdx: 0, channelName: 'Public Channel', @@ -701,7 +723,9 @@ class ConnectionProvider with ChangeNotifier { } catch (e) { _error = 'Failed to configure public channel: $e'; debugPrint('❌ [Provider] Public channel configuration failed: $e'); - debugPrint(' This may be normal if the channel is pre-configured in firmware'); + debugPrint( + ' This may be normal if the channel is pre-configured in firmware', + ); notifyListeners(); rethrow; // Re-throw to notify caller of failure } @@ -752,9 +776,13 @@ class ConnectionProvider with ChangeNotifier { // Log path status and retry info if (contact != null) { if (retryAttempt > 0) { - debugPrint('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)'); + debugPrint( + '🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)', + ); } else { - debugPrint('📤 [ConnectionProvider] Sending message to ${contact.advName}'); + debugPrint( + '📤 [ConnectionProvider] Sending message to ${contact.advName}', + ); } debugPrint(' Type: ${contact.type.displayName}'); debugPrint(' Path status: ${contact.pathDescription}'); @@ -764,12 +792,17 @@ class ConnectionProvider with ChangeNotifier { debugPrint(' âš ī¸ No path available - will use flood mode'); } } else if (retryAttempt > 0) { - debugPrint('🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)'); + debugPrint( + '🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)', + ); } // Track pending operation for auto-recovery (if contact not found in radio) if (contact != null) { - final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); _pendingSendOperations[operationId] = _PendingSendOperation( contactPublicKey: contactPublicKey, text: text, @@ -777,7 +810,9 @@ class ConnectionProvider with ChangeNotifier { contact: contact, retryAttempt: retryAttempt, ); - debugPrint(' 📝 Tracked pending operation for auto-recovery: $operationId'); + debugPrint( + ' 📝 Tracked pending operation for auto-recovery: $operationId', + ); } // IMPORTANT: Track pending message BEFORE sending to avoid race condition @@ -785,7 +820,9 @@ class ConnectionProvider with ChangeNotifier { // the callback will fire before we add the message ID to the queue. if (messageId != null) { _messageDeliveryTracker.trackPendingMessage(messageId); - debugPrint(' Added message ID to pending queue BEFORE sending: $messageId'); + debugPrint( + ' Added message ID to pending queue BEFORE sending: $messageId', + ); } // Send the message with retry attempt info @@ -798,7 +835,10 @@ class ConnectionProvider with ChangeNotifier { // Clear pending operation after successful send (no error) // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically if (contact != null) { - final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final operationId = contactPublicKey + .sublist(0, 6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); // Use a small delay to allow error response to arrive before clearing Future.delayed(const Duration(milliseconds: 500), () { _pendingSendOperations.remove(operationId); @@ -838,7 +878,9 @@ class ConnectionProvider with ChangeNotifier { await _bleService.sendChannelMessage(channelIdx: channelIdx, text: text); debugPrint('✅ [ConnectionProvider] BLE send completed'); - debugPrint(' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}'); + debugPrint( + ' Checking messageId: ${messageId != null ? "Present ($messageId)" : "NULL"}', + ); // Channel messages are ephemeral (not persisted) - mark as "sent" immediately // They don't have ACK/TAG mechanism like direct messages @@ -1347,7 +1389,9 @@ class ConnectionProvider with ChangeNotifier { }, ); - debugPrint(' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages'); + debugPrint( + ' After iteration ${i + 1}: hasMore=$hasMore, _noMoreMessages=$_noMoreMessages', + ); if (!hasMore) { debugPrint(' ✅ No more messages available, stopping sync'); diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 6a5f2d5..c505b48 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../models/contact.dart'; import '../services/cayenne_lpp_parser.dart'; @@ -23,11 +22,14 @@ class ContactsProvider with ChangeNotifier { if (_isInitialized) return; try { - debugPrint('đŸ“Ļ [ContactsProvider] Early loading persisted contacts (no filtering)...'); + debugPrint( + 'đŸ“Ļ [ContactsProvider] Early loading persisted contacts (no filtering)...', + ); final storedContacts = await _storageService.loadContacts(); // Add stored contacts (excluding any with all-zeros public key) - const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; for (final contact in storedContacts) { // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) if (contact.publicKeyHex == publicChannelKey) { @@ -37,7 +39,9 @@ class ContactsProvider with ChangeNotifier { } _isInitialized = true; - debugPrint('✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts'); + debugPrint( + '✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts', + ); // Ensure public channel exists after loading _ensurePublicChannelExists(); @@ -68,7 +72,8 @@ class ContactsProvider with ChangeNotifier { ); // Add stored contacts (excluding any with all-zeros public key) - const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; for (final contact in storedContacts) { // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) if (contact.publicKeyHex == publicChannelKey) { @@ -78,7 +83,9 @@ class ContactsProvider with ChangeNotifier { } _isInitialized = true; - debugPrint('✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts'); + debugPrint( + '✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts', + ); // Ensure public channel exists after loading _ensurePublicChannelExists(); @@ -93,10 +100,14 @@ class ContactsProvider with ChangeNotifier { /// Remove self-contact from loaded contacts (called after BLE connection established) void _removeSelfContact(Uint8List devicePublicKey) { - final selfKeyHex = devicePublicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final selfKeyHex = devicePublicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); if (_contacts.containsKey(selfKeyHex)) { final selfContact = _contacts[selfKeyHex]!; - debugPrint('đŸ—‘ī¸ [ContactsProvider] Removing self-contact: ${selfContact.advName}'); + debugPrint( + 'đŸ—‘ī¸ [ContactsProvider] Removing self-contact: ${selfContact.advName}', + ); _contacts.remove(selfKeyHex); _persistContacts(); notifyListeners(); @@ -106,11 +117,14 @@ class ContactsProvider with ChangeNotifier { /// Ensure public channel always exists in the list void _ensurePublicChannelExists() { // Public channel has all-zeros public key (32 bytes = 64 hex chars) - const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; if (!_contacts.containsKey(publicChannelKey)) { // Create a pseudo-contact for the public channel (ephemeral broadcast) _contacts[publicChannelKey] = Contact( - publicKey: Uint8List.fromList(List.filled(32, 0)), // Zero key for public + publicKey: Uint8List.fromList( + List.filled(32, 0), + ), // Zero key for public type: ContactType.channel, // Channel type (not room!) flags: 0, outPathLen: 0, @@ -128,7 +142,8 @@ class ContactsProvider with ChangeNotifier { Future _persistContacts() async { try { // Don't persist the public channel pseudo-contact (all zeros key) - const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; + const publicChannelKey = + '0000000000000000000000000000000000000000000000000000000000000000'; final contactsToSave = _contacts.entries .where((entry) => entry.key != publicChannelKey) .map((entry) => entry.value) @@ -159,7 +174,8 @@ class ContactsProvider with ChangeNotifier { /// Get both rooms and channels (destinations for SAR markers) List get roomsAndChannels { _ensurePublicChannelExists(); - return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen); + return contacts.where((c) => c.isRoom || c.isChannel).toList() + ..sort(_sortByLastSeen); } /// Get contacts with location (for map display) @@ -179,8 +195,11 @@ class ContactsProvider with ChangeNotifier { /// Excludes contacts that match the device's own public key void addOrUpdateContact(Contact contact, {Uint8List? devicePublicKey}) { // Don't add contacts that match our device's public key - if (devicePublicKey != null && _publicKeysMatch(contact.publicKey, devicePublicKey)) { - debugPrint('â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}'); + if (devicePublicKey != null && + _publicKeysMatch(contact.publicKey, devicePublicKey)) { + debugPrint( + 'â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', + ); return; } @@ -192,8 +211,13 @@ class ContactsProvider with ChangeNotifier { // New contact - add initial location to history if available updatedContact = contact.copyWith(isNew: true); if (contact.advertLocation != null) { - final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000); - updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp); + final timestamp = DateTime.fromMillisecondsSinceEpoch( + contact.lastAdvert * 1000, + ); + updatedContact = updatedContact.addAdvertLocation( + contact.advertLocation!, + timestamp, + ); } } else { // Existing contact - preserve history and isNew status @@ -207,8 +231,13 @@ class ContactsProvider with ChangeNotifier { // Add new location to history if location has changed if (contact.advertLocation != null) { - final timestamp = DateTime.fromMillisecondsSinceEpoch(contact.lastAdvert * 1000); - updatedContact = updatedContact.addAdvertLocation(contact.advertLocation!, timestamp); + final timestamp = DateTime.fromMillisecondsSinceEpoch( + contact.lastAdvert * 1000, + ); + updatedContact = updatedContact.addAdvertLocation( + contact.advertLocation!, + timestamp, + ); } } @@ -232,15 +261,20 @@ class ContactsProvider with ChangeNotifier { int excluded = 0; for (final contact in contacts) { // Don't add contacts that match our device's public key - if (devicePublicKey != null && _publicKeysMatch(contact.publicKey, devicePublicKey)) { - debugPrint('â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}'); + if (devicePublicKey != null && + _publicKeysMatch(contact.publicKey, devicePublicKey)) { + debugPrint( + 'â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}', + ); excluded++; continue; } _contacts[contact.publicKeyHex] = contact; } if (excluded > 0) { - debugPrint('â„šī¸ [ContactsProvider] Excluded $excluded contact(s) matching device public key'); + debugPrint( + 'â„šī¸ [ContactsProvider] Excluded $excluded contact(s) matching device public key', + ); } _persistContacts(); notifyListeners(); @@ -249,7 +283,9 @@ class ContactsProvider with ChangeNotifier { /// Update contact telemetry void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { debugPrint('📊 [ContactsProvider] updateTelemetry() called'); - debugPrint(' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + ' Public key prefix (hex): ${publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); debugPrint(' LPP data size: ${lppData.length} bytes'); // Find contact by public key prefix @@ -303,8 +339,9 @@ class ContactsProvider with ChangeNotifier { /// Find contact by public key Contact? findContactByKey(Uint8List publicKey) { - final keyHex = - publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + final keyHex = publicKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); return _contacts[keyHex]; } diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 69a453b..3340488 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -7,11 +6,7 @@ import '../models/map_drawing.dart'; import '../utils/drawing_message_parser.dart'; /// Drawing mode state -enum DrawingMode { - none, - line, - rectangle, -} +enum DrawingMode { none, line, rectangle } /// Provider for managing map drawings class DrawingProvider with ChangeNotifier { @@ -123,7 +118,8 @@ class DrawingProvider with ChangeNotifier { /// Update rectangle end point (for preview) void updateRectangleEndPoint(LatLng endPoint) { - if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) return; + if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) + return; // Create preview rectangle _currentDrawing = RectangleDrawing( @@ -270,7 +266,8 @@ class DrawingProvider with ChangeNotifier { createdAt: DateTime.now(), points: _currentLinePoints, ); - } else if (_drawingMode == DrawingMode.rectangle && _currentDrawing != null) { + } else if (_drawingMode == DrawingMode.rectangle && + _currentDrawing != null) { return _currentDrawing; } return null; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 0297b3a..f762c4d 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../models/message.dart'; import '../models/contact.dart'; @@ -41,7 +40,8 @@ class MessagesProvider with ChangeNotifier { required String messageId, required Contact contact, int retryAttempt, - })? sendMessageCallback; + })? + sendMessageCallback; List get messages => List.unmodifiable(_messages); @@ -93,10 +93,7 @@ class MessagesProvider with ChangeNotifier { /// Get count of unread messages (excluding sent messages and system messages) int get unreadCount => _messages - .where((m) => - !m.isRead && - !m.isSentMessage && - !m.isSystemMessage) + .where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage) .length; /// Initialize and load persisted messages @@ -124,7 +121,9 @@ class MessagesProvider with ChangeNotifier { } _isInitialized = true; - debugPrint('✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages'); + debugPrint( + '✅ [MessagesProvider] Loaded ${storedMessages.length} persisted messages', + ); notifyListeners(); } catch (e) { debugPrint('❌ [MessagesProvider] Error initializing: $e'); @@ -135,7 +134,10 @@ class MessagesProvider with ChangeNotifier { /// Add a message /// If [contactLookup] function is provided, it will be used to match channel /// message senders with known contacts by name - void addMessage(Message message, {String Function(String name)? contactLookup}) { + void addMessage( + Message message, { + String Function(String name)? contactLookup, + }) { // Always enhance message with SAR parser to detect SAR markers final enhancedMessage = SarMessageParser.enhanceMessage(message); @@ -165,7 +167,9 @@ class MessagesProvider with ChangeNotifier { // Debug: Check if message is SAR if (message.text.startsWith('S:')) { - debugPrint('🔍 [MessagesProvider] Processing SAR message: ${message.text}'); + debugPrint( + '🔍 [MessagesProvider] Processing SAR message: ${message.text}', + ); debugPrint(' isSarMarker: ${finalMessage.isSarMarker}'); debugPrint(' sarMarkerType: ${finalMessage.sarMarkerType}'); } @@ -176,8 +180,12 @@ class MessagesProvider with ChangeNotifier { // - Multiple paths in the network // - Syncing messages from device queue if (_isDuplicate(finalMessage)) { - debugPrint('âš ī¸ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}'); - debugPrint(' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...'); + debugPrint( + 'âš ī¸ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}', + ); + debugPrint( + ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', + ); return; // Skip duplicate } @@ -282,7 +290,9 @@ class MessagesProvider with ChangeNotifier { } } - debugPrint('đŸ“Ĩ [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates'); + debugPrint( + 'đŸ“Ĩ [MessagesProvider] Added $addedCount messages, skipped $duplicateCount duplicates', + ); // Persist to storage asynchronously _persistMessages(); @@ -291,15 +301,22 @@ class MessagesProvider with ChangeNotifier { } /// Trigger urgent notification for SAR marker - Future _triggerSarNotification(Message message, SarMarker marker) async { + Future _triggerSarNotification( + Message message, + SarMarker marker, + ) async { try { // Format coordinates - final coords = '${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}'; + final coords = + '${marker.location.latitude.toStringAsFixed(5)}, ${marker.location.longitude.toStringAsFixed(5)}'; // Get sender name from message - final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown'; + final senderName = + message.senderName ?? message.senderKeyShort ?? 'Unknown'; - debugPrint('🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}'); + debugPrint( + '🔔 [MessagesProvider] Triggering SAR notification for ${marker.type.displayName}', + ); debugPrint(' Sender: $senderName'); debugPrint(' Coordinates: $coords'); @@ -319,7 +336,8 @@ class MessagesProvider with ChangeNotifier { Future _triggerMessageNotification(Message message) async { try { // Get sender name from message - final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown'; + final senderName = + message.senderName ?? message.senderKeyShort ?? 'Unknown'; // Determine if it's a channel message final isChannelMessage = message.isChannelMessage; @@ -329,13 +347,17 @@ class MessagesProvider with ChangeNotifier { if (isChannelMessage) { // You could map channelIdx to channel name here if needed // For now, use "Public" for channel 0 - channelName = message.channelIdx == 0 ? 'Public' : 'Channel ${message.channelIdx}'; + channelName = message.channelIdx == 0 + ? 'Public' + : 'Channel ${message.channelIdx}'; } debugPrint('🔔 [MessagesProvider] Triggering message notification'); debugPrint(' Sender: $senderName'); debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); - debugPrint(' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...'); + debugPrint( + ' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...', + ); await _notificationService.showMessageNotification( senderName: senderName, @@ -345,7 +367,9 @@ class MessagesProvider with ChangeNotifier { localizations: _localizations, ); } catch (e) { - debugPrint('❌ [MessagesProvider] Error triggering message notification: $e'); + debugPrint( + '❌ [MessagesProvider] Error triggering message notification: $e', + ); } } @@ -361,10 +385,12 @@ class MessagesProvider with ChangeNotifier { /// Get messages for a specific contact List getMessagesForContact(String senderKeyShort) { return _messages - .where((m) => - m.isContactMessage && - m.senderKeyShort != null && - m.senderKeyShort!.startsWith(senderKeyShort)) + .where( + (m) => + m.isContactMessage && + m.senderKeyShort != null && + m.senderKeyShort!.startsWith(senderKeyShort), + ) .toList(); } @@ -417,7 +443,9 @@ class MessagesProvider with ChangeNotifier { void markAllAsRead() { bool hasChanges = false; for (int i = 0; i < _messages.length; i++) { - if (!_messages[i].isRead && !_messages[i].isSentMessage && !_messages[i].isSystemMessage) { + if (!_messages[i].isRead && + !_messages[i].isSentMessage && + !_messages[i].isSystemMessage) { _messages[i] = _messages[i].copyWith(isRead: true); hasChanges = true; } @@ -552,14 +580,18 @@ class MessagesProvider with ChangeNotifier { debugPrint(' Message ID: ${message.id}'); debugPrint(' Message type: ${message.messageType}'); debugPrint(' Initial status: ${message.deliveryStatus}'); - debugPrint(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...'); + debugPrint( + ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', + ); // Always enhance message with SAR parser to detect SAR markers final enhancedMessage = SarMessageParser.enhanceMessage(message); // Check for duplicates (shouldn't happen for sent messages, but be safe) if (_isDuplicate(enhancedMessage)) { - debugPrint('âš ī¸ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}'); + debugPrint( + 'âš ī¸ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', + ); return; } @@ -597,12 +629,20 @@ class MessagesProvider with ChangeNotifier { } /// Update message status to sent with ACK tag - void markMessageSent(String messageId, int expectedAckTag, int suggestedTimeoutMs) { + void markMessageSent( + String messageId, + int expectedAckTag, + int suggestedTimeoutMs, + ) { debugPrint('📤 [MessagesProvider] markMessageSent called'); debugPrint(' Message ID: $messageId'); - debugPrint(' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})'); + debugPrint( + ' Expected ACK tag: $expectedAckTag (0x${expectedAckTag.toRadixString(16).padLeft(8, '0')})', + ); debugPrint(' Timeout: ${suggestedTimeoutMs}ms'); - debugPrint(' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}'); + debugPrint( + ' Current pending ACKs before adding: ${_pendingSentMessages.keys.toList()}', + ); final index = _messages.indexWhere((m) => m.id == messageId); debugPrint(' Message index in list: $index'); @@ -611,7 +651,9 @@ class MessagesProvider with ChangeNotifier { final message = _messages[index]; debugPrint(' Current status: ${message.deliveryStatus}'); debugPrint(' Message type: ${message.messageType}'); - debugPrint(' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...'); + debugPrint( + ' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', + ); final updatedMessage = message.copyWith( deliveryStatus: MessageDeliveryStatus.sent, @@ -624,24 +666,34 @@ class MessagesProvider with ChangeNotifier { if (expectedAckTag > 0 && suggestedTimeoutMs > 0) { // Track by ACK tag for matching with delivery confirmation _pendingSentMessages[expectedAckTag] = updatedMessage; - debugPrint(' ✅ Added to pending messages map with ACK: $expectedAckTag'); + debugPrint( + ' ✅ Added to pending messages map with ACK: $expectedAckTag', + ); debugPrint(' Total pending messages: ${_pendingSentMessages.length}'); - debugPrint(' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}'); + debugPrint( + ' Pending ACKs after adding: ${_pendingSentMessages.keys.toList()}', + ); // Start timeout timer _timeoutTimers[expectedAckTag] = Timer( Duration(milliseconds: suggestedTimeoutMs), () { - debugPrint('âąī¸ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)'); + debugPrint( + 'âąī¸ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)', + ); if (_pendingSentMessages.containsKey(expectedAckTag)) { markMessageFailed(messageId); } }, ); - debugPrint('âąī¸ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)'); + debugPrint( + 'âąī¸ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)', + ); } else { - debugPrint(' â„šī¸ Channel message (no ACK tracking) - marked as sent immediately'); + debugPrint( + ' â„šī¸ Channel message (no ACK tracking) - marked as sent immediately', + ); } debugPrint(' Calling notifyListeners() to update UI with "sent" status'); @@ -661,7 +713,12 @@ class MessagesProvider with ChangeNotifier { } /// Handle echo detection for public channel messages - void handleMessageEcho(String messageId, int echoCount, int snrRaw, int rssiDbm) { + void handleMessageEcho( + String messageId, + int echoCount, + int snrRaw, + int rssiDbm, + ) { debugPrint('🔊 [MessagesProvider] handleMessageEcho called'); debugPrint(' Message ID: $messageId'); debugPrint(' Echo count: $echoCount'); @@ -672,7 +729,9 @@ class MessagesProvider with ChangeNotifier { final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { final message = _messages[index]; - debugPrint(' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...'); + debugPrint( + ' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...', + ); // Update echo count final updatedMessage = message.copyWith( @@ -692,8 +751,12 @@ class MessagesProvider with ChangeNotifier { /// Update message status to delivered with RTT void markMessageDelivered(int ackCode, int roundTripTimeMs) { - debugPrint('🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms'); - debugPrint(' Current pending messages: ${_pendingSentMessages.keys.toList()}'); + debugPrint( + '🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms', + ); + debugPrint( + ' Current pending messages: ${_pendingSentMessages.keys.toList()}', + ); debugPrint(' Total messages in list: ${_messages.length}'); debugPrint(' Looking for ACK: $ackCode'); @@ -722,7 +785,9 @@ class MessagesProvider with ChangeNotifier { // Clear retry tracking on successful delivery _retryManager.clearRetry(message.id); - debugPrint('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)'); + debugPrint( + '✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)', + ); debugPrint(' Updated status to: ${updatedMessage.deliveryStatus}'); debugPrint(' Calling notifyListeners() to update UI'); @@ -731,33 +796,58 @@ class MessagesProvider with ChangeNotifier { debugPrint(' ✅ notifyListeners() called successfully'); } else { - debugPrint('âš ī¸ [MessagesProvider] Message not found in messages list (index=-1)'); - debugPrint(' This should never happen - message was in pending map but not in messages list'); + debugPrint( + 'âš ī¸ [MessagesProvider] Message not found in messages list (index=-1)', + ); + debugPrint( + ' This should never happen - message was in pending map but not in messages list', + ); } } else { - debugPrint('âš ī¸ [MessagesProvider] No pending message found for ACK code: $ackCode'); + debugPrint( + 'âš ī¸ [MessagesProvider] No pending message found for ACK code: $ackCode', + ); debugPrint(' Pending ACK codes: ${_pendingSentMessages.keys.toList()}'); debugPrint(' This means either:'); - debugPrint(' 1. markMessageSent() was never called for this message (ACK tag not stored)'); - debugPrint(' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT'); + debugPrint( + ' 1. markMessageSent() was never called for this message (ACK tag not stored)', + ); + debugPrint( + ' 2. The ACK code from PUSH_CODE_SEND_CONFIRMED doesn\'t match the expected ACK tag from RESP_CODE_SENT', + ); debugPrint(' 3. The message was already delivered or timed out'); debugPrint(' Searching all messages for debugging...'); // Debug: Search for any message with this ACK tag - final matchingMessages = _messages.where((m) => m.expectedAckTag == ackCode).toList(); + final matchingMessages = _messages + .where((m) => m.expectedAckTag == ackCode) + .toList(); if (matchingMessages.isNotEmpty) { - debugPrint(' âš ī¸ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:'); + debugPrint( + ' âš ī¸ Found ${matchingMessages.length} message(s) with matching ACK tag but NOT in pending map:', + ); for (final m in matchingMessages) { - debugPrint(' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}'); + debugPrint( + ' - Message ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}', + ); } - debugPrint(' This indicates the message was sent but never added to _pendingSentMessages map'); - debugPrint(' Likely cause: markMessageSent() was not called with correct message ID'); + debugPrint( + ' This indicates the message was sent but never added to _pendingSentMessages map', + ); + debugPrint( + ' Likely cause: markMessageSent() was not called with correct message ID', + ); } else { debugPrint(' No messages found with ACK tag $ackCode'); debugPrint(' Recent sent messages:'); - final sentMessages = _messages.where((m) => m.isSentMessage).take(5).toList(); + final sentMessages = _messages + .where((m) => m.isSentMessage) + .take(5) + .toList(); for (final m in sentMessages) { - debugPrint(' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}'); + debugPrint( + ' - ID: ${m.id}, Status: ${m.deliveryStatus}, ACK: ${m.expectedAckTag}', + ); } } } @@ -767,7 +857,9 @@ class MessagesProvider with ChangeNotifier { void markMessageFailed(String messageId) { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { - debugPrint('âš ī¸ [MessagesProvider] markMessageFailed: Message not found: $messageId'); + debugPrint( + 'âš ī¸ [MessagesProvider] markMessageFailed: Message not found: $messageId', + ); return; } @@ -783,7 +875,8 @@ class MessagesProvider with ChangeNotifier { if (contact != null && _retryManager.canRetry(message, contact)) { // RETRY: Contact has path and retry attempts < 3 _scheduleRetry(messageId, message, contact); - } else if (contact != null && _retryManager.shouldUseFloodFallback(message, contact)) { + } else if (contact != null && + _retryManager.shouldUseFloodFallback(message, contact)) { // FLOOD FALLBACK: After 3 retries failed, try flood once _sendWithFloodMode(messageId, message, contact); } else { @@ -797,7 +890,9 @@ class MessagesProvider with ChangeNotifier { final nextAttempt = message.retryAttempt + 1; final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt); - debugPrint('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId'); + debugPrint( + '🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId', + ); debugPrint(' Timeout: ${timeout}ms'); // Update message with new retry attempt @@ -823,7 +918,9 @@ class MessagesProvider with ChangeNotifier { // Schedule actual retry after delay Timer(Duration(milliseconds: timeout), () async { - debugPrint('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId'); + debugPrint( + '⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId', + ); if (sendMessageCallback != null) { await sendMessageCallback!( contactPublicKey: contact.publicKey, @@ -833,7 +930,9 @@ class MessagesProvider with ChangeNotifier { retryAttempt: nextAttempt, ); } else { - debugPrint('âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot retry'); + debugPrint( + 'âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot retry', + ); } }); @@ -842,8 +941,14 @@ class MessagesProvider with ChangeNotifier { } /// Send message with flood mode as last resort - Future _sendWithFloodMode(String messageId, Message message, Contact contact) async { - debugPrint('🌊 [MessagesProvider] Trying flood mode for message $messageId'); + Future _sendWithFloodMode( + String messageId, + Message message, + Contact contact, + ) async { + debugPrint( + '🌊 [MessagesProvider] Trying flood mode for message $messageId', + ); final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { @@ -871,7 +976,9 @@ class MessagesProvider with ChangeNotifier { retryAttempt: 0, // Reset attempt for flood ); } else { - debugPrint('âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot send flood'); + debugPrint( + 'âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot send flood', + ); } _persistMessages(); @@ -907,7 +1014,9 @@ class MessagesProvider with ChangeNotifier { Future resendMessage(String messageId) async { final index = _messages.indexWhere((m) => m.id == messageId); if (index == -1) { - debugPrint('âš ī¸ [MessagesProvider] resendMessage: Message not found: $messageId'); + debugPrint( + 'âš ī¸ [MessagesProvider] resendMessage: Message not found: $messageId', + ); return; } @@ -915,7 +1024,9 @@ class MessagesProvider with ChangeNotifier { final contact = _messageContactMap[messageId]; if (contact == null) { - debugPrint('âš ī¸ [MessagesProvider] Cannot resend: Contact not found for message $messageId'); + debugPrint( + 'âš ī¸ [MessagesProvider] Cannot resend: Contact not found for message $messageId', + ); return; } @@ -944,7 +1055,9 @@ class MessagesProvider with ChangeNotifier { retryAttempt: 0, ); } else { - debugPrint('âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot resend'); + debugPrint( + 'âš ī¸ [MessagesProvider] sendMessageCallback not set, cannot resend', + ); } _persistMessages(); diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 89cbe1e..5434cbe 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -209,7 +209,9 @@ class _DeviceConfigScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToSave(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToSave(e.toString()), + ), backgroundColor: Colors.red, ), ); @@ -282,7 +284,9 @@ class _DeviceConfigScreenState extends State { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToSave(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToSave(e.toString()), + ), backgroundColor: Colors.red, ), ); @@ -365,9 +369,13 @@ class _DeviceConfigScreenState extends State { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.failedToGetLocation(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.failedToGetLocation(e.toString()), + ), + ), + ); } } } @@ -452,14 +460,33 @@ class _DeviceConfigScreenState extends State { ), ), const SizedBox(height: 16), - _InfoRow(AppLocalizations.of(context)!.bleName, deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown), - _InfoRow(AppLocalizations.of(context)!.meshName, deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet), - _InfoRow(AppLocalizations.of(context)!.type, _getDeviceTypeString(context, deviceInfo.deviceType)), - _InfoRow(AppLocalizations.of(context)!.model, deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown), - _InfoRow(AppLocalizations.of(context)!.version, deviceInfo.semanticVersion ?? AppLocalizations.of(context)!.unknown), + _InfoRow( + AppLocalizations.of(context)!.bleName, + deviceInfo.deviceName ?? + AppLocalizations.of(context)!.unknown, + ), + _InfoRow( + AppLocalizations.of(context)!.meshName, + deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet, + ), + _InfoRow( + AppLocalizations.of(context)!.type, + _getDeviceTypeString(context, deviceInfo.deviceType), + ), + _InfoRow( + AppLocalizations.of(context)!.model, + deviceInfo.manufacturerModel ?? + AppLocalizations.of(context)!.unknown, + ), + _InfoRow( + AppLocalizations.of(context)!.version, + deviceInfo.semanticVersion ?? + AppLocalizations.of(context)!.unknown, + ), _InfoRow( AppLocalizations.of(context)!.buildDate, - deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown, + deviceInfo.firmwareBuildDate ?? + AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.firmware, @@ -467,11 +494,13 @@ class _DeviceConfigScreenState extends State { ), _InfoRow( AppLocalizations.of(context)!.maxContacts, - deviceInfo.maxContacts?.toString() ?? AppLocalizations.of(context)!.unknown, + deviceInfo.maxContacts?.toString() ?? + AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.maxChannels, - deviceInfo.maxChannels?.toString() ?? AppLocalizations.of(context)!.unknown, + deviceInfo.maxChannels?.toString() ?? + AppLocalizations.of(context)!.unknown, ), _CopyableInfoRow( AppLocalizations.of(context)!.publicKey, @@ -521,7 +550,9 @@ class _DeviceConfigScreenState extends State { decoration: InputDecoration( labelText: AppLocalizations.of(context)!.meshNetworkName, border: const OutlineInputBorder(), - helperText: AppLocalizations.of(context)!.nameBroadcastInMesh, + helperText: AppLocalizations.of( + context, + )!.nameBroadcastInMesh, ), ), const SizedBox(height: 8), @@ -531,7 +562,9 @@ class _DeviceConfigScreenState extends State { children: [ Expanded( child: Text( - AppLocalizations.of(context)!.telemetryAndLocationSharing, + AppLocalizations.of( + context, + )!.telemetryAndLocationSharing, style: theme.textTheme.bodyMedium, ), ), @@ -584,7 +617,9 @@ class _DeviceConfigScreenState extends State { IconButton.filled( onPressed: _useCurrentLocation, icon: const Icon(Icons.my_location, size: 20), - tooltip: AppLocalizations.of(context)!.useCurrentLocation, + tooltip: AppLocalizations.of( + context, + )!.useCurrentLocation, ), ], ), @@ -627,7 +662,9 @@ class _DeviceConfigScreenState extends State { decoration: InputDecoration( labelText: AppLocalizations.of(context)!.frequencyMHz, border: const OutlineInputBorder(), - helperText: AppLocalizations.of(context)!.frequencyExample, + helperText: AppLocalizations.of( + context, + )!.frequencyExample, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, @@ -637,7 +674,7 @@ class _DeviceConfigScreenState extends State { // Bandwidth DropdownButtonFormField( - value: _selectedBandwidth, + initialValue: _selectedBandwidth, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.bandwidth, border: const OutlineInputBorder(), @@ -660,7 +697,7 @@ class _DeviceConfigScreenState extends State { // Spreading Factor DropdownButtonFormField( - value: _selectedSpreadingFactor, + initialValue: _selectedSpreadingFactor, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.spreadingFactor, border: const OutlineInputBorder(), @@ -685,7 +722,7 @@ class _DeviceConfigScreenState extends State { // Coding Rate DropdownButtonFormField( - value: _selectedCodingRate, + initialValue: _selectedCodingRate, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.codingRate, border: const OutlineInputBorder(), @@ -714,7 +751,9 @@ class _DeviceConfigScreenState extends State { decoration: InputDecoration( labelText: AppLocalizations.of(context)!.txPowerDbm, border: const OutlineInputBorder(), - helperText: AppLocalizations.of(context)!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), + helperText: AppLocalizations.of( + context, + )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), ), keyboardType: TextInputType.number, ), @@ -815,7 +854,11 @@ class _CopyableInfoRow extends StatelessWidget { Clipboard.setData(ClipboardData(text: value)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.copiedToClipboardShort(label)), + content: Text( + AppLocalizations.of( + context, + )!.copiedToClipboardShort(label), + ), duration: const Duration(seconds: 2), backgroundColor: Colors.green, ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 04215c6..cbe6657 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -426,7 +426,7 @@ class _HomeScreenState extends State ), const SizedBox(width: 8), Text( - '${rssi} dBm', + '$rssi dBm', style: TextStyle( color: signalColor, fontSize: 12, @@ -789,8 +789,9 @@ class _HomeScreenState extends State Navigator.push( context, MaterialPageRoute( - builder: (context) => - PacketLogScreen(bleService: provider.bleService), + builder: (context) => PacketLogScreen( + bleService: provider.bleService, + ), ), ); }, diff --git a/lib/screens/map_management_screen.dart b/lib/screens/map_management_screen.dart index ae49e44..80387c5 100644 --- a/lib/screens/map_management_screen.dart +++ b/lib/screens/map_management_screen.dart @@ -109,7 +109,9 @@ class _MapManagementScreenState extends State { } catch (e) { if (!mounted) return; setState(() { - _statusMessage = AppLocalizations.of(context)!.errorLoadingStats(e.toString()); + _statusMessage = AppLocalizations.of( + context, + )!.errorLoadingStats(e.toString()); _isLoading = false; }); } @@ -153,7 +155,9 @@ class _MapManagementScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.mbtilesImportedSuccessfully), + content: Text( + AppLocalizations.of(context)!.mbtilesImportedSuccessfully, + ), backgroundColor: Colors.green, ), ); @@ -174,7 +178,9 @@ class _MapManagementScreenState extends State { builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.deleteMbtilesConfirmTitle), content: Text( - AppLocalizations.of(context)!.deleteMbtilesConfirmMessage(metadata.name), + AppLocalizations.of( + context, + )!.deleteMbtilesConfirmMessage(metadata.name), ), actions: [ TextButton( @@ -205,7 +211,9 @@ class _MapManagementScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.mbtilesDeletedSuccessfully), + content: Text( + AppLocalizations.of(context)!.mbtilesDeletedSuccessfully, + ), backgroundColor: Colors.green, ), ); @@ -246,13 +254,21 @@ class _MapManagementScreenState extends State { // Validate zoom levels final minZoomResult = validator.validateZoomLevel(_minZoom); if (!minZoomResult.isValid) { - _showError(AppLocalizations.of(context)!.minZoomError(minZoomResult.errorMessage!)); + _showError( + AppLocalizations.of( + context, + )!.minZoomError(minZoomResult.errorMessage!), + ); return; } final maxZoomResult = validator.validateZoomLevel(_maxZoom); if (!maxZoomResult.isValid) { - _showError(AppLocalizations.of(context)!.maxZoomError(maxZoomResult.errorMessage!)); + _showError( + AppLocalizations.of( + context, + )!.maxZoomError(maxZoomResult.errorMessage!), + ); return; } @@ -261,10 +277,7 @@ class _MapManagementScreenState extends State { return; } - final bounds = LatLngBounds( - LatLng(south!, west!), - LatLng(north!, east!), - ); + final bounds = LatLngBounds(LatLng(south!, west!), LatLng(north!, east!)); if (!mounted) return; setState(() { @@ -291,7 +304,9 @@ class _MapManagementScreenState extends State { if (!mounted) return; setState(() { _isDownloading = false; - _statusMessage = AppLocalizations.of(context)!.downloadCompletedSuccessfully; + _statusMessage = AppLocalizations.of( + context, + )!.downloadCompletedSuccessfully; }); await _loadCacheStats(); @@ -308,7 +323,9 @@ class _MapManagementScreenState extends State { if (!mounted) return; setState(() { _isDownloading = false; - _statusMessage = AppLocalizations.of(context)!.downloadFailed(e.toString()); + _statusMessage = AppLocalizations.of( + context, + )!.downloadFailed(e.toString()); }); _showError(AppLocalizations.of(context)!.downloadFailed(e.toString())); } @@ -317,7 +334,9 @@ class _MapManagementScreenState extends State { Future _cancelDownload() async { try { if (!mounted) return; - setState(() => _statusMessage = AppLocalizations.of(context)!.cancellingDownload); + setState( + () => _statusMessage = AppLocalizations.of(context)!.cancellingDownload, + ); await widget.tileCacheService.cancelDownload(); @@ -341,7 +360,9 @@ class _MapManagementScreenState extends State { if (!mounted) return; setState(() { _isDownloading = false; - _statusMessage = AppLocalizations.of(context)!.cancelFailed(e.toString()); + _statusMessage = AppLocalizations.of( + context, + )!.cancelFailed(e.toString()); }); _showError(AppLocalizations.of(context)!.cancelFailed(e.toString())); } @@ -352,9 +373,7 @@ class _MapManagementScreenState extends State { context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearMapsConfirmTitle), - content: Text( - AppLocalizations.of(context)!.clearMapsConfirmMessage, - ), + content: Text(AppLocalizations.of(context)!.clearMapsConfirmMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -382,7 +401,9 @@ class _MapManagementScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.cacheClearedSuccessfully), + content: Text( + AppLocalizations.of(context)!.cacheClearedSuccessfully, + ), backgroundColor: Colors.green, ), ); @@ -410,10 +431,13 @@ class _MapManagementScreenState extends State { // Export to temporary directory first (works on all platforms) final tempDir = await getTemporaryDirectory(); - final fileName = 'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc'; + final fileName = + 'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc'; final tempFilePath = '${tempDir.path}/$fileName'; - final exportedCount = await widget.tileCacheService.exportStore(tempFilePath); + final exportedCount = await widget.tileCacheService.exportStore( + tempFilePath, + ); if (!mounted) return; setState(() { @@ -443,7 +467,9 @@ class _MapManagementScreenState extends State { if (result.status == ShareResultStatus.success) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.exportSuccess(exportedCount)), + content: Text( + AppLocalizations.of(context)!.exportSuccess(exportedCount), + ), backgroundColor: Colors.green, ), ); @@ -484,7 +510,9 @@ class _MapManagementScreenState extends State { // Optional: Preview stores in archive before importing try { - final stores = await widget.tileCacheService.listArchiveStores(filePath); + final stores = await widget.tileCacheService.listArchiveStores( + filePath, + ); debugPrint('Archive contains stores: $stores'); } catch (e) { debugPrint('Could not list stores: $e'); @@ -504,7 +532,9 @@ class _MapManagementScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - AppLocalizations.of(context)!.importSuccess(importResult['successfulStores'] as int), + AppLocalizations.of( + context, + )!.importSuccess(importResult['successfulStores'] as int), ), backgroundColor: Colors.green, ), @@ -535,9 +565,7 @@ class _MapManagementScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.mapManagement), - ), + appBar: AppBar(title: Text(AppLocalizations.of(context)!.mapManagement)), body: _isLoading ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( @@ -622,7 +650,10 @@ class _MapManagementScreenState extends State { Icon(icon, size: 20, color: Colors.grey[600]), const SizedBox(width: 12), Expanded( - child: Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), + child: Text( + label, + style: const TextStyle(fontWeight: FontWeight.w500), + ), ), Text(value, style: TextStyle(color: Colors.grey[600])), ], @@ -667,7 +698,11 @@ class _MapManagementScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - Icon(Icons.map_outlined, size: 48, color: Colors.grey[400]), + Icon( + Icons.map_outlined, + size: 48, + color: Colors.grey[400], + ), const SizedBox(height: 8), Text( AppLocalizations.of(context)!.noMbtilesFiles, @@ -678,74 +713,79 @@ class _MapManagementScreenState extends State { ), ) else - ..._mbtilesFiles.map((metadata) => Card( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - margin: const EdgeInsets.only(bottom: 8), - child: ExpansionTile( - leading: Icon( - metadata.isVector ? Icons.layers : Icons.image, - color: metadata.isVector ? Colors.blue : Colors.orange, - ), - title: Text( - metadata.name, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - '${metadata.fileSizeFormatted} â€ĸ ${metadata.format?.toUpperCase() ?? "Unknown"}', - ), - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (metadata.description != null) ...[ - Text( - metadata.description!, - style: TextStyle(color: Colors.grey[700]), - ), - const SizedBox(height: 12), - ], - _buildInfoRow( - AppLocalizations.of(context)!.zoomLevels, - '${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}', + ..._mbtilesFiles.map( + (metadata) => Card( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + margin: const EdgeInsets.only(bottom: 8), + child: ExpansionTile( + leading: Icon( + metadata.isVector ? Icons.layers : Icons.image, + color: metadata.isVector ? Colors.blue : Colors.orange, + ), + title: Text( + metadata.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${metadata.fileSizeFormatted} â€ĸ ${metadata.format?.toUpperCase() ?? "Unknown"}', + ), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (metadata.description != null) ...[ + Text( + metadata.description!, + style: TextStyle(color: Colors.grey[700]), ), - if (metadata.bounds != null) - _buildInfoRow( - AppLocalizations.of(context)!.bounds, - metadata.bounds!, - ), - if (metadata.isVector) ...[ - _buildInfoRow( - AppLocalizations.of(context)!.type, - AppLocalizations.of(context)!.vectorTiles, - ), - _buildInfoRow( - AppLocalizations.of(context)!.schema, - _mbtilesService.getVectorSchema(metadata) ?? - AppLocalizations.of(context)!.unknown, - ), - ], const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton.icon( - onPressed: () => _deleteMbtilesFile(metadata), - icon: const Icon(Icons.delete, color: Colors.red), - label: Text( - AppLocalizations.of(context)!.delete, - style: const TextStyle(color: Colors.red), - ), - ), - ], + ], + _buildInfoRow( + AppLocalizations.of(context)!.zoomLevels, + '${metadata.minZoom ?? "?"} - ${metadata.maxZoom ?? "?"}', + ), + if (metadata.bounds != null) + _buildInfoRow( + AppLocalizations.of(context)!.bounds, + metadata.bounds!, + ), + if (metadata.isVector) ...[ + _buildInfoRow( + AppLocalizations.of(context)!.type, + AppLocalizations.of(context)!.vectorTiles, + ), + _buildInfoRow( + AppLocalizations.of(context)!.schema, + _mbtilesService.getVectorSchema(metadata) ?? + AppLocalizations.of(context)!.unknown, ), ], - ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _deleteMbtilesFile(metadata), + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + label: Text( + AppLocalizations.of(context)!.delete, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ], ), - ], - ), - )), + ), + ], + ), + ), + ), const SizedBox(height: 16), @@ -840,10 +880,7 @@ class _MapManagementScreenState extends State { ), ), Expanded( - child: Text( - value, - style: TextStyle(color: Colors.grey[800]), - ), + child: Text(value, style: TextStyle(color: Colors.grey[800])), ), ], ), @@ -865,7 +902,7 @@ class _MapManagementScreenState extends State { // Map Layer Selection DropdownButtonFormField( - value: _selectedLayer, + initialValue: _selectedLayer, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.mapLayer, border: const OutlineInputBorder(), @@ -876,11 +913,13 @@ class _MapManagementScreenState extends State { child: Text(layer.getLocalizedName(context)), ); }).toList(), - onChanged: _isDownloading ? null : (layer) { - if (layer != null) { - setState(() => _selectedLayer = layer); - } - }, + onChanged: _isDownloading + ? null + : (layer) { + if (layer != null) { + setState(() => _selectedLayer = layer); + } + }, ), const SizedBox(height: 16), @@ -900,7 +939,9 @@ class _MapManagementScreenState extends State { border: const OutlineInputBorder(), hintText: '46.1', ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), enabled: !_isDownloading, ), ), @@ -913,7 +954,9 @@ class _MapManagementScreenState extends State { border: const OutlineInputBorder(), hintText: '46.0', ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), enabled: !_isDownloading, ), ), @@ -930,7 +973,9 @@ class _MapManagementScreenState extends State { border: const OutlineInputBorder(), hintText: '14.6', ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), enabled: !_isDownloading, ), ), @@ -943,7 +988,9 @@ class _MapManagementScreenState extends State { border: const OutlineInputBorder(), hintText: '14.4', ), - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), enabled: !_isDownloading, ), ), @@ -970,14 +1017,16 @@ class _MapManagementScreenState extends State { max: 19, divisions: 18, label: '$_minZoom', - onChanged: _isDownloading ? null : (value) { - setState(() { - _minZoom = value.toInt(); - if (_minZoom > _maxZoom) { - _maxZoom = _minZoom; - } - }); - }, + onChanged: _isDownloading + ? null + : (value) { + setState(() { + _minZoom = value.toInt(); + if (_minZoom > _maxZoom) { + _maxZoom = _minZoom; + } + }); + }, ), ], ), @@ -994,14 +1043,16 @@ class _MapManagementScreenState extends State { max: 19, divisions: 18, label: '$_maxZoom', - onChanged: _isDownloading ? null : (value) { - setState(() { - _maxZoom = value.toInt(); - if (_maxZoom < _minZoom) { - _minZoom = _maxZoom; - } - }); - }, + onChanged: _isDownloading + ? null + : (value) { + setState(() { + _maxZoom = value.toInt(); + if (_maxZoom < _minZoom) { + _minZoom = _maxZoom; + } + }); + }, ), ], ), @@ -1018,7 +1069,9 @@ class _MapManagementScreenState extends State { color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), border: Border.all( - color: Theme.of(context).colorScheme.primary.withOpacity(0.3), + color: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.3), ), ), child: Column( @@ -1029,10 +1082,13 @@ class _MapManagementScreenState extends State { children: [ Expanded( child: Text( - _statusMessage ?? AppLocalizations.of(context)!.downloadingDots, + _statusMessage ?? + AppLocalizations.of(context)!.downloadingDots, style: TextStyle( fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onPrimaryContainer, + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, ), overflow: TextOverflow.ellipsis, ), @@ -1043,7 +1099,9 @@ class _MapManagementScreenState extends State { style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, - color: Theme.of(context).colorScheme.onPrimaryContainer, + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, ), ), ], @@ -1054,7 +1112,9 @@ class _MapManagementScreenState extends State { child: LinearProgressIndicator( value: _downloadProgress / 100, minHeight: 8, - backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.2), + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.2), valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary, ), diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 7240781..e21f1b9 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -35,7 +35,8 @@ class _MessagesTabState extends State { String? _highlightedMessageId; // Message destination state - String _destinationType = MessageDestinationPreferences.destinationTypeChannel; + String _destinationType = + MessageDestinationPreferences.destinationTypeChannel; Contact? _selectedRecipient; /// Helper method to compare two public keys for equality @@ -129,7 +130,8 @@ class _MessagesTabState extends State { /// Load saved message destination from preferences Future _loadSavedDestination() async { - final savedDestination = await MessageDestinationPreferences.getDestination(); + final savedDestination = + await MessageDestinationPreferences.getDestination(); if (savedDestination == null || !mounted) { // Default to public channel @@ -160,7 +162,8 @@ class _MessagesTabState extends State { 'âš ī¸ [MessagesTab] Saved recipient not found, falling back to public channel', ); setState(() { - _destinationType = MessageDestinationPreferences.destinationTypeChannel; + _destinationType = + MessageDestinationPreferences.destinationTypeChannel; _selectedRecipient = null; }); await MessageDestinationPreferences.clearDestination(); @@ -197,7 +200,8 @@ class _MessagesTabState extends State { /// Handle recipient selection Future _onRecipientSelected(String type, Contact? recipient) async { // Get display name before async gap - final recipientName = type == MessageDestinationPreferences.destinationTypeChannel + final recipientName = + type == MessageDestinationPreferences.destinationTypeChannel ? AppLocalizations.of(context)!.publicChannel : (recipient?.displayName ?? recipient?.advName ?? 'Unknown'); @@ -214,17 +218,16 @@ class _MessagesTabState extends State { // Show confirmation toast if (!mounted) return; - ToastLogger.success( - context, - 'Messages will be sent to: $recipientName', - ); + ToastLogger.success(context, 'Messages will be sent to: $recipientName'); } /// Get icon for current destination type IconData _getDestinationIcon() { - if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { return Icons.public; - } else if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) { + } else if (_destinationType == + MessageDestinationPreferences.destinationTypeRoom) { return Icons.meeting_room; } else { return Icons.person; @@ -234,10 +237,12 @@ class _MessagesTabState extends State { /// Get tooltip for destination button String _getDestinationTooltip() { final l10n = AppLocalizations.of(context)!; - if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { return '${l10n.publicChannel} (tap to change)'; } else if (_selectedRecipient != null) { - final recipientName = _selectedRecipient!.displayName ?? _selectedRecipient!.advName; + final recipientName = + _selectedRecipient!.displayName ?? _selectedRecipient!.advName; return '$recipientName (tap to change)'; } return 'Select recipient'; @@ -259,7 +264,8 @@ class _MessagesTabState extends State { try { // Check destination type and send accordingly - if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + if (_destinationType == + MessageDestinationPreferences.destinationTypeChannel) { // Send to public channel await _sendToChannel(text, connectionProvider, messagesProvider); } else if (_selectedRecipient != null) { @@ -272,7 +278,9 @@ class _MessagesTabState extends State { ); } else { // Fallback to public channel if no recipient selected - debugPrint('âš ī¸ [MessagesTab] No recipient selected, falling back to channel'); + debugPrint( + 'âš ī¸ [MessagesTab] No recipient selected, falling back to channel', + ); await _sendToChannel(text, connectionProvider, messagesProvider); } @@ -422,7 +430,8 @@ class _MessagesTabState extends State { if (sendToChannel) { // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final messageId = + '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Get current device's public key (first 6 bytes) @@ -455,10 +464,7 @@ class _MessagesTabState extends State { ); if (!mounted) return; - ToastLogger.success( - context, - 'SAR marker broadcast to public channel', - ); + ToastLogger.success(context, 'SAR marker broadcast to public channel'); } else { // Create message ID final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; @@ -490,7 +496,7 @@ class _MessagesTabState extends State { final contactsProvider = context.read(); final roomContact = contactsProvider.contacts.where((c) { return c.publicKey.length >= roomPublicKey!.length && - _publicKeysMatch(c.publicKey, roomPublicKey!); + _publicKeysMatch(c.publicKey, roomPublicKey); }).firstOrNull; // Send SAR message to selected room (persisted and immutable) @@ -507,10 +513,7 @@ class _MessagesTabState extends State { } if (!mounted) return; - ToastLogger.success( - context, - 'SAR marker sent to room', - ); + ToastLogger.success(context, 'SAR marker sent to room'); } } catch (e) { if (!mounted) return; @@ -615,7 +618,8 @@ class _MessagesTabState extends State { itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; - final isHighlighted = message.id == _highlightedMessageId; + final isHighlighted = + message.id == _highlightedMessageId; // Display system messages with minimal styling if (message.isSystemMessage) { @@ -680,10 +684,18 @@ class _MessagesTabState extends State { tooltip: _getDestinationTooltip(), onPressed: _showRecipientSelector, style: IconButton.styleFrom( - backgroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel - ? Theme.of(context).colorScheme.surfaceContainerHighest + backgroundColor: + _destinationType == + MessageDestinationPreferences + .destinationTypeChannel + ? Theme.of( + context, + ).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.secondaryContainer, - foregroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel + foregroundColor: + _destinationType == + MessageDestinationPreferences + .destinationTypeChannel ? Theme.of(context).colorScheme.onSurface : Theme.of(context).colorScheme.onSecondaryContainer, ), @@ -699,9 +711,7 @@ class _MessagesTabState extends State { maxLengthEnforcement: MaxLengthEnforcement.enforced, style: const TextStyle(fontSize: 14), decoration: InputDecoration( - hintText: AppLocalizations.of( - context, - )!.typeYourMessage, + hintText: AppLocalizations.of(context)!.typeYourMessage, hintStyle: const TextStyle(fontSize: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), @@ -718,9 +728,7 @@ class _MessagesTabState extends State { fontSize: 10, color: _characterCount > _maxCharacters * 0.9 ? Colors.orange - : Theme.of( - context, - ).textTheme.bodySmall?.color, + : Theme.of(context).textTheme.bodySmall?.color, ), suffixIcon: IconButton( icon: Icon( @@ -885,7 +893,10 @@ class _MessageBubble extends StatelessWidget { onTap: () { Clipboard.setData(ClipboardData(text: message.text)); Navigator.pop(context); - ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard); + ToastLogger.success( + context, + AppLocalizations.of(context)!.textCopiedToClipboard, + ); }, ), // Delete message option @@ -1100,8 +1111,8 @@ class _MessageBubble extends StatelessWidget { color: isHighlighted ? Theme.of(context).colorScheme.primaryContainer : isSarMarker - ? _getSarMarkerColor(context, isDarkMode) - : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), + ? _getSarMarkerColor(context, isDarkMode) + : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), borderRadius: BorderRadius.circular(12), border: isHighlighted ? Border.all( @@ -1109,43 +1120,45 @@ class _MessageBubble extends StatelessWidget { width: 3, ) : isSarMarker - ? Border.all( - color: _getSarMarkerBorderColor(context, isDarkMode), - width: 2, - ) - : isOwnMessage - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - width: 1.5, - ) - : !message.isRead && - !message.isSentMessage && - !message.isSystemMessage - ? Border.all(color: Colors.blue, width: 1.5) - : null, + ? Border.all( + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 2, + ) + : isOwnMessage + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, boxShadow: isHighlighted ? [ BoxShadow( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.5), blurRadius: 12, spreadRadius: 2, offset: const Offset(0, 2), ), ] : isSarMarker - ? [ - BoxShadow( - color: _getSarMarkerBorderColor( - context, - isDarkMode, - ).withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, + ? [ + BoxShadow( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1272,9 +1285,12 @@ class _MessageBubble extends StatelessWidget { children: [ Text( // Show template name (sarNotes) if available, otherwise show localized type name - message.sarNotes != null && message.sarNotes!.isNotEmpty + message.sarNotes != null && + message.sarNotes!.isNotEmpty ? message.sarNotes! - : message.sarMarkerType!.getLocalizedName(context), + : message.sarMarkerType!.getLocalizedName( + context, + ), style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), @@ -1411,7 +1427,7 @@ class _MessageBubble extends StatelessWidget { ).colorScheme.primaryContainer.withValues(alpha: 0.15); } else { // Others' messages: default surface color - return Theme.of(context).colorScheme.surfaceVariant; + return Theme.of(context).colorScheme.surfaceContainerHighest; } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d61be15..d7b11e4 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -86,10 +86,7 @@ class _SettingsScreenState extends State { _locationService.onError = (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error), - backgroundColor: Colors.orange, - ), + SnackBar(content: Text(error), backgroundColor: Colors.orange), ); } }; @@ -119,7 +116,8 @@ class _SettingsScreenState extends State { // Load settings and restore tracking state final prefs = await SharedPreferences.getInstance(); - final wasTracking = prefs.getBool('background_tracking_enabled') ?? false; + final wasTracking = + prefs.getBool('background_tracking_enabled') ?? false; if (wasTracking) { await _startBackgroundTracking(); @@ -239,7 +237,9 @@ class _SettingsScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToLoadSampleData(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToLoadSampleData(e.toString()), + ), backgroundColor: Colors.red, ), ); @@ -296,7 +296,9 @@ class _SettingsScreenState extends State { // Permission granted ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.locationPermissionGranted), + content: Text( + AppLocalizations.of(context)!.locationPermissionGranted, + ), backgroundColor: Colors.green, ), ); @@ -305,7 +307,9 @@ class _SettingsScreenState extends State { // Permission denied ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.locationPermissionRequiredForGps), + content: Text( + AppLocalizations.of(context)!.locationPermissionRequiredForGps, + ), backgroundColor: Colors.orange, duration: const Duration(seconds: 4), ), @@ -316,7 +320,9 @@ class _SettingsScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.locationPermissionAlreadyGranted), + content: Text( + AppLocalizations.of(context)!.locationPermissionAlreadyGranted, + ), backgroundColor: Colors.blue, ), ); @@ -325,10 +331,7 @@ class _SettingsScreenState extends State { debugPrint('Error handling location permission: $e'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), ); } } @@ -366,9 +369,7 @@ class _SettingsScreenState extends State { context: context, builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearAllDataConfirmTitle), - content: Text( - AppLocalizations.of(context)!.clearAllDataConfirmMessage, - ), + content: Text(AppLocalizations.of(context)!.clearAllDataConfirmMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -438,7 +439,9 @@ class _SettingsScreenState extends State { builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.visibility_off), title: Text(AppLocalizations.of(context)!.simpleMode), - subtitle: Text(AppLocalizations.of(context)!.simpleModeDescription), + subtitle: Text( + AppLocalizations.of(context)!.simpleModeDescription, + ), value: appProvider.isSimpleMode, onChanged: (value) async { await appProvider.toggleSimpleMode(value); @@ -485,19 +488,27 @@ class _SettingsScreenState extends State { switch (permission) { case LocationPermission.always: - statusText = AppLocalizations.of(context)!.locationPermissionGrantedAlways; + statusText = AppLocalizations.of( + context, + )!.locationPermissionGrantedAlways; statusColor = Colors.green; break; case LocationPermission.whileInUse: - statusText = AppLocalizations.of(context)!.locationPermissionGrantedWhileInUse; + statusText = AppLocalizations.of( + context, + )!.locationPermissionGrantedWhileInUse; statusColor = Colors.green; break; case LocationPermission.denied: - statusText = AppLocalizations.of(context)!.locationPermissionDeniedTapToRequest; + statusText = AppLocalizations.of( + context, + )!.locationPermissionDeniedTapToRequest; statusColor = Colors.orange; break; case LocationPermission.deniedForever: - statusText = AppLocalizations.of(context)!.locationPermissionPermanentlyDeniedOpenSettings; + statusText = AppLocalizations.of( + context, + )!.locationPermissionPermanentlyDeniedOpenSettings; statusColor = Colors.red; break; default: @@ -505,10 +516,7 @@ class _SettingsScreenState extends State { statusColor = Colors.grey; } - return Text( - statusText, - style: TextStyle(color: statusColor), - ); + return Text(statusText, style: TextStyle(color: statusColor)); }, ), trailing: const Icon(Icons.chevron_right), @@ -517,13 +525,17 @@ class _SettingsScreenState extends State { const Divider(), // Location Settings Section - _buildSectionHeader(AppLocalizations.of(context)!.locationBroadcasting), + _buildSectionHeader( + AppLocalizations.of(context)!.locationBroadcasting, + ), // Automatic tracking settings SwitchListTile( secondary: const Icon(Icons.location_on), title: Text(AppLocalizations.of(context)!.autoLocationTracking), - subtitle: Text(AppLocalizations.of(context)!.automaticallyBroadcastPosition), + subtitle: Text( + AppLocalizations.of(context)!.automaticallyBroadcastPosition, + ), value: _locationService.isTracking, onChanged: (value) { if (value) { @@ -538,7 +550,9 @@ class _SettingsScreenState extends State { ListTile( leading: const Icon(Icons.tune), title: Text(AppLocalizations.of(context)!.configureTracking), - subtitle: Text(AppLocalizations.of(context)!.distanceAndTimeThresholds), + subtitle: Text( + AppLocalizations.of(context)!.distanceAndTimeThresholds, + ), trailing: const Icon(Icons.chevron_right), onTap: () => _showTrackingConfigDialog(), ), @@ -654,7 +668,9 @@ class _SettingsScreenState extends State { context: context, builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( - title: Text(AppLocalizations.of(context)!.locationTrackingConfiguration), + title: Text( + AppLocalizations.of(context)!.locationTrackingConfiguration, + ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -663,29 +679,31 @@ class _SettingsScreenState extends State { // Description Text( AppLocalizations.of(context)!.configureWhenLocationBroadcasts, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey), ), const SizedBox(height: 24), // Minimum Distance Text( AppLocalizations.of(context)!.minimumDistance, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( - AppLocalizations.of(context)!.broadcastAfterMoving(tempMinDistance.toStringAsFixed(0)), + AppLocalizations.of( + context, + )!.broadcastAfterMoving(tempMinDistance.toStringAsFixed(0)), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), SliderTheme( - data: SliderTheme.of(context).copyWith( - showValueIndicator: ShowValueIndicator.always, - ), + data: SliderTheme.of( + context, + ).copyWith(showValueIndicator: ShowValueIndicator.onDrag), child: Slider( value: tempMinDistance, min: 1, @@ -718,20 +736,22 @@ class _SettingsScreenState extends State { // Maximum Distance Text( AppLocalizations.of(context)!.maximumDistance, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( - AppLocalizations.of(context)!.alwaysBroadcastAfterMoving(tempMaxDistance.toStringAsFixed(0)), + AppLocalizations.of(context)!.alwaysBroadcastAfterMoving( + tempMaxDistance.toStringAsFixed(0), + ), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), SliderTheme( - data: SliderTheme.of(context).copyWith( - showValueIndicator: ShowValueIndicator.always, - ), + data: SliderTheme.of( + context, + ).copyWith(showValueIndicator: ShowValueIndicator.onDrag), child: Slider( value: tempMaxDistance, min: tempMinDistance, @@ -750,9 +770,14 @@ class _SettingsScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${tempMinDistance.toStringAsFixed(0)}m', - style: Theme.of(context).textTheme.bodySmall), - Text('500m', style: Theme.of(context).textTheme.bodySmall), + Text( + '${tempMinDistance.toStringAsFixed(0)}m', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + '500m', + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ), @@ -761,20 +786,22 @@ class _SettingsScreenState extends State { // Minimum Time Interval Text( AppLocalizations.of(context)!.minimumTimeInterval, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( - AppLocalizations.of(context)!.alwaysBroadcastEvery(_formatDuration(tempTimeInterval)), + AppLocalizations.of( + context, + )!.alwaysBroadcastEvery(_formatDuration(tempTimeInterval)), style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), SliderTheme( - data: SliderTheme.of(context).copyWith( - showValueIndicator: ShowValueIndicator.always, - ), + data: SliderTheme.of( + context, + ).copyWith(showValueIndicator: ShowValueIndicator.onDrag), child: Slider( value: tempTimeInterval.toDouble(), min: 10, @@ -794,7 +821,10 @@ class _SettingsScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('10s', style: Theme.of(context).textTheme.bodySmall), - Text('10min', style: Theme.of(context).textTheme.bodySmall), + Text( + '10min', + style: Theme.of(context).textTheme.bodySmall, + ), ], ), ), @@ -819,7 +849,9 @@ class _SettingsScreenState extends State { // Update tracking if active if (_locationService.isTracking) { - await _locationService.updateDistanceThreshold(tempMinDistance); + await _locationService.updateDistanceThreshold( + tempMinDistance, + ); } // Close dialog before setState @@ -897,7 +929,9 @@ class _SettingsScreenState extends State { ), ], ), - subtitle: Text(AppLocalizations.of(context)!.alertEmergencyMode), + subtitle: Text( + AppLocalizations.of(context)!.alertEmergencyMode, + ), value: AppThemeMode.sarRed, groupValue: _selectedTheme, onChanged: (value) { @@ -945,7 +979,9 @@ class _SettingsScreenState extends State { ), ], ), - subtitle: Text(AppLocalizations.of(context)!.sarNavyBlueDescription), + subtitle: Text( + AppLocalizations.of(context)!.sarNavyBlueDescription, + ), value: AppThemeMode.sarNavyBlue, groupValue: _selectedTheme, onChanged: (value) { @@ -1043,9 +1079,7 @@ class _SettingsScreenState extends State { style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.aboutDescription, - ), + Text(AppLocalizations.of(context)!.aboutDescription), const SizedBox(height: 16), Text( AppLocalizations.of(context)!.technologiesUsed, @@ -1054,9 +1088,7 @@ class _SettingsScreenState extends State { ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.technologiesList, - ), + Text(AppLocalizations.of(context)!.technologiesList), ], ), ), diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index a4c46f4..cb770c5 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/widgets.dart'; -import 'package:flutter_background_service/flutter_background_service.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'meshcore_ble_service.dart'; @@ -32,7 +30,9 @@ class BackgroundLocationService { /// additional platform-specific configuration is required. Future startTracking({double distanceThreshold = 10.0}) async { if (!_isInitialized || _bleService == null) { - debugPrint('âš ī¸ [BackgroundLocation] Service not initialized or BLE service null'); + debugPrint( + 'âš ī¸ [BackgroundLocation] Service not initialized or BLE service null', + ); return false; } @@ -52,7 +52,9 @@ class BackgroundLocationService { } if (permission == LocationPermission.deniedForever) { - debugPrint('âš ī¸ [BackgroundLocation] Location permission permanently denied'); + debugPrint( + 'âš ī¸ [BackgroundLocation] Location permission permanently denied', + ); return false; } @@ -64,60 +66,77 @@ class BackgroundLocationService { // Start listening to position updates Position? lastPosition; try { - _positionSubscription = Geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.best, - distanceFilter: distanceThreshold.toInt(), - ), - ).listen((Position position) async { - debugPrint('📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}'); - - // Calculate distance from last position - if (lastPosition != null) { - final distance = Geolocator.distanceBetween( - lastPosition!.latitude, - lastPosition!.longitude, - position.latitude, - position.longitude, - ); - - debugPrint(' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)'); - - // Skip if haven't moved enough - if (distance < distanceThreshold) { - return; - } - } - - // Update last position - lastPosition = position; - - // Save to preferences - await prefs.setDouble(_prefKeyLastLat, position.latitude); - await prefs.setDouble(_prefKeyLastLon, position.longitude); - - // Update device's advertised location - if (_bleService != null && _bleService!.isConnected) { - try { - debugPrint('📤 [BackgroundLocation] Updating device location...'); - await _bleService!.setAdvertLatLon( - latitude: position.latitude, - longitude: position.longitude, + _positionSubscription = + Geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.best, + distanceFilter: distanceThreshold.toInt(), + ), + ).listen((Position position) async { + debugPrint( + '📍 [BackgroundLocation] New position: ${position.latitude}, ${position.longitude}', ); - // Send advertisement to mesh network - debugPrint('📡 [BackgroundLocation] Broadcasting self advertisement...'); - await _bleService!.sendSelfAdvert(floodMode: true); - debugPrint('✅ [BackgroundLocation] Location update sent successfully'); - } catch (e) { - debugPrint('❌ [BackgroundLocation] Failed to send location update: $e'); - } - } else { - debugPrint('âš ī¸ [BackgroundLocation] BLE disconnected, cannot send update'); - } - }); + // Calculate distance from last position + if (lastPosition != null) { + final distance = Geolocator.distanceBetween( + lastPosition!.latitude, + lastPosition!.longitude, + position.latitude, + position.longitude, + ); - debugPrint('✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold'); + debugPrint( + ' Distance moved: ${distance.toStringAsFixed(1)}m (threshold: ${distanceThreshold}m)', + ); + + // Skip if haven't moved enough + if (distance < distanceThreshold) { + return; + } + } + + // Update last position + lastPosition = position; + + // Save to preferences + await prefs.setDouble(_prefKeyLastLat, position.latitude); + await prefs.setDouble(_prefKeyLastLon, position.longitude); + + // Update device's advertised location + if (_bleService != null && _bleService!.isConnected) { + try { + debugPrint( + '📤 [BackgroundLocation] Updating device location...', + ); + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Send advertisement to mesh network + debugPrint( + '📡 [BackgroundLocation] Broadcasting self advertisement...', + ); + await _bleService!.sendSelfAdvert(floodMode: true); + debugPrint( + '✅ [BackgroundLocation] Location update sent successfully', + ); + } catch (e) { + debugPrint( + '❌ [BackgroundLocation] Failed to send location update: $e', + ); + } + } else { + debugPrint( + 'âš ī¸ [BackgroundLocation] BLE disconnected, cannot send update', + ); + } + }); + + debugPrint( + '✅ [BackgroundLocation] Tracking started with ${distanceThreshold}m threshold', + ); return true; } catch (e) { debugPrint('❌ [BackgroundLocation] Failed to start tracking: $e'); @@ -141,7 +160,9 @@ class BackgroundLocationService { Future updateDistanceThreshold(double distance) async { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_prefKeyDistance, distance); - debugPrint('📏 [BackgroundLocation] Distance threshold updated to ${distance}m'); + debugPrint( + '📏 [BackgroundLocation] Distance threshold updated to ${distance}m', + ); // Restart tracking if currently enabled final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false; diff --git a/lib/services/ble/ble_command_queue.dart b/lib/services/ble/ble_command_queue.dart index faaacf0..7f4f233 100644 --- a/lib/services/ble/ble_command_queue.dart +++ b/lib/services/ble/ble_command_queue.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; /// Type of response expected from a command @@ -95,7 +94,8 @@ class BleCommandQueue { Duration? timeout, }) async { // Determine timeout based on response type - final cmdTimeout = timeout ?? + final cmdTimeout = + timeout ?? (responseType == CommandResponseType.data ? const Duration(seconds: 10) : const Duration(seconds: 5)); @@ -114,7 +114,9 @@ class BleCommandQueue { _queue.add(command); onQueueSizeChanged?.call(_queue.length); - debugPrint('📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})'); + debugPrint( + '📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})', + ); // Start processing if not already running if (!_isProcessing) { @@ -125,9 +127,13 @@ class BleCommandQueue { return command.completer.future.timeout( cmdTimeout, onTimeout: () { - debugPrint('âąī¸ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s'); + debugPrint( + 'âąī¸ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s', + ); _pendingResponses.remove(commandCode); - throw TimeoutException('Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out'); + throw TimeoutException( + 'Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out', + ); }, ); } @@ -152,7 +158,9 @@ class BleCommandQueue { final remainingDelay = _minDelayMs - elapsed.inMilliseconds; if (remainingDelay > 0) { - debugPrint('â¸ī¸ [CommandQueue] Waiting ${remainingDelay}ms before next command'); + debugPrint( + 'â¸ī¸ [CommandQueue] Waiting ${remainingDelay}ms before next command', + ); await Future.delayed(Duration(milliseconds: remainingDelay)); } } @@ -170,7 +178,9 @@ class BleCommandQueue { // Execute command (handled by BleCommandSender) // The completer will be completed by completeCommand() when response arrives - debugPrint('📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}'); + debugPrint( + '📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}', + ); // For fire-and-forget commands, complete immediately if (command.responseType == CommandResponseType.none) { @@ -209,7 +219,9 @@ class BleCommandQueue { void completeCommand(int responseCode, T data) { final command = _pendingResponses.remove(responseCode); if (command != null) { - debugPrint('✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}'); + debugPrint( + '✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}', + ); if (!command.completer.isCompleted) { command.completer.complete(data); } @@ -219,10 +231,16 @@ class BleCommandQueue { /// Complete a pending command with error /// /// Called by BleResponseHandler when RESP_CODE_ERR is received - void completeCommandWithError(int commandCode, String error, {int? errorCode}) { + void completeCommandWithError( + int commandCode, + String error, { + int? errorCode, + }) { final command = _pendingResponses.remove(commandCode); if (command != null) { - debugPrint('❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)'); + debugPrint( + '❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)', + ); if (!command.completer.isCompleted) { command.completer.completeError( Exception('Command failed: $error (error code: $errorCode)'), @@ -245,7 +263,9 @@ class BleCommandQueue { /// Clear all pending commands (use with caution) void clear() { - debugPrint('đŸ—‘ī¸ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)'); + debugPrint( + 'đŸ—‘ī¸ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)', + ); // Complete all pending commands with error for (final command in _pendingResponses.values) { diff --git a/lib/services/ble/ble_command_sender.dart b/lib/services/ble/ble_command_sender.dart index cc62b37..2f899cf 100644 --- a/lib/services/ble/ble_command_sender.dart +++ b/lib/services/ble/ble_command_sender.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../meshcore_opcode_names.dart'; @@ -130,10 +129,13 @@ class BleCommandSender { debugPrint('📤 [TX] Sending command: $opcodeName ($opcodeHex)'); debugPrint(' Data size: ${data.length} bytes'); - debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + debugPrint( + ' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); // Check if the characteristic supports write without response - final supportsWriteWithoutResponse = _rxCharacteristic!.properties.writeWithoutResponse; + final supportsWriteWithoutResponse = + _rxCharacteristic!.properties.writeWithoutResponse; final supportsWrite = _rxCharacteristic!.properties.write; if (supportsWriteWithoutResponse) { @@ -160,15 +162,21 @@ class BleCommandSender { } /// Log a packet - void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { + void _logPacket( + Uint8List data, + PacketDirection direction, { + int? responseCode, + }) { // Add new packet - _packetLogs.add(BlePacketLog( - timestamp: DateTime.now(), - rawData: data, - direction: direction, - responseCode: responseCode, - description: _getPacketDescription(responseCode), - )); + _packetLogs.add( + BlePacketLog( + timestamp: DateTime.now(), + rawData: data, + direction: direction, + responseCode: responseCode, + description: _getPacketDescription(responseCode), + ), + ); // Limit log size to prevent memory issues if (_packetLogs.length > _maxLogSize) { diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart index 0f86aa8..5ad285d 100644 --- a/lib/services/ble/ble_response_handler.dart +++ b/lib/services/ble/ble_response_handler.dart @@ -17,24 +17,38 @@ import 'ble_command_queue.dart'; typedef OnContactCallback = void Function(Contact contact); typedef OnContactsCompleteCallback = void Function(List contacts); typedef OnMessageCallback = void Function(Message message); -typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); +typedef OnTelemetryCallback = + void Function(Uint8List publicKey, Uint8List lppData); typedef OnSelfInfoCallback = void Function(Map selfInfo); typedef OnDeviceInfoCallback = void Function(Map deviceInfo); typedef OnNoMoreMessagesCallback = void Function(); typedef OnMessageWaitingCallback = void Function(); -typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag); +typedef OnLoginSuccessCallback = + void Function( + Uint8List publicKeyPrefix, + int permissions, + bool isAdmin, + int tag, + ); typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix); typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey); typedef OnPathUpdatedCallback = void Function(Uint8List publicKey); -typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode); -typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs); -typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); -typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); -typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); +typedef OnMessageSentCallback = + void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode); +typedef OnMessageDeliveredCallback = + void Function(int ackCode, int roundTripTimeMs); +typedef OnStatusResponseCallback = + void Function(Uint8List publicKeyPrefix, Uint8List statusData); +typedef OnBinaryResponseCallback = + void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); +typedef OnBatteryAndStorageCallback = + void Function(int millivolts, int? usedKb, int? totalKb); typedef OnErrorCallback = void Function(String error, {int? errorCode}); typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); -typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName); -typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm); +typedef OnChannelInfoCallback = + void Function(int channelIdx, String channelName); +typedef OnMessageEchoDetectedCallback = + void Function(String messageId, int echoCount, int snrRaw, int rssiDbm); /// Processes incoming responses from the BLE device class BleResponseHandler { @@ -118,12 +132,18 @@ class BleResponseHandler { final responseCode = reader.readByte(); // Get opcode name for logging - final opcodeName = MeshCoreOpcodeNames.getOpcodeName(responseCode, isTx: false); - final opcodeHex = '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'; + final opcodeName = MeshCoreOpcodeNames.getOpcodeName( + responseCode, + isTx: false, + ); + final opcodeHex = + '0x${responseCode.toRadixString(16).padLeft(2, '0').toUpperCase()}'; debugPrint('đŸ“Ĩ [RX] Received: $opcodeName ($opcodeHex)'); debugPrint(' Data size: ${data.length} bytes'); - debugPrint(' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + debugPrint( + ' Hex: ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); debugPrint(' Payload: ${reader.remainingBytesCount} bytes'); // Log RX packet (before processing so we capture everything) @@ -254,7 +274,9 @@ class BleResponseHandler { try { final contact = FrameParser.parseContact(reader); debugPrint(' ✅ [Contact] Parsed successfully: ${contact.advName}'); - debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})'); + debugPrint( + ' outPathLen: ${contact.outPathLen} (${contact.pathDescription})', + ); _pendingContacts.add(contact); onContactReceived?.call(contact); } catch (e) { @@ -416,7 +438,9 @@ class BleResponseHandler { /// Handle LogRxData push - includes extensive decoding logic void _handleLogRxData(BufferReader reader) { try { - debugPrint(' [LogRxData] Parsing log rx data from over-the-air packet...'); + debugPrint( + ' [LogRxData] Parsing log rx data from over-the-air packet...', + ); final data = reader.readRemainingBytes(); if (data.length < 2) { @@ -445,25 +469,35 @@ class BleResponseHandler { final payloadType = (header >> 2) & 0x0F; final pathLen = rawPacketData[1]; - debugPrint(' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}'); + debugPrint( + ' Packet type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', + ); if (pathLen > 0 && rawPacketData.length >= 2 + pathLen) { final path = rawPacketData.sublist(2, 2 + pathLen); - final pathStr = path.map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}').join(' → '); + final pathStr = path + .map((b) => '0x${b.toRadixString(16).padLeft(2, '0')}') + .join(' → '); debugPrint(' Path ($pathLen hops): $pathStr'); // Highlight multi-hop packets if (pathLen > 1) { - debugPrint(' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}'); + debugPrint( + ' 🔄 MULTI-HOP PACKET! Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}', + ); } // Check if our node hash is in the path if (_ourNodeHash != null && path.contains(_ourNodeHash!)) { - debugPrint(' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅'); + debugPrint( + ' ✅✅✅ ECHO DETECTED! Path contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) ✅✅✅', + ); if (path[0] == _ourNodeHash) { debugPrint(' 👉 WE are the original sender!'); } else { - debugPrint(' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network'); + debugPrint( + ' 👉 Original sender: 0x${path[0].toRadixString(16).padLeft(2, '0')}, WE sent it to the network', + ); } } else { debugPrint(' â„šī¸ Does NOT contain our hash (not our message)'); @@ -526,7 +560,11 @@ class BleResponseHandler { hash = hash & 0xFFFFFFFF; // Keep 32-bit } if (packet.length > 16) { - for (int i = packet.length ~/ 2; i < packet.length ~/ 2 + 8 && i < packet.length; i++) { + for ( + int i = packet.length ~/ 2; + i < packet.length ~/ 2 + 8 && i < packet.length; + i++ + ) { hash = ((hash << 5) - hash) + packet[i]; hash = hash & 0xFFFFFFFF; } @@ -543,7 +581,9 @@ class BleResponseHandler { /// Check if received packet is an echo of a sent message void _checkForEcho(Uint8List rawPacket, int snrRaw, int rssiDbm) { try { - debugPrint(' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes'); + debugPrint( + ' 🔍 [Echo] _checkForEcho called, packet size: ${rawPacket.length} bytes', + ); // Need at least header + path_len if (rawPacket.length < 2) { @@ -553,7 +593,9 @@ class BleResponseHandler { final header = rawPacket[0]; final payloadType = (header >> 2) & 0x0F; - debugPrint(' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}'); + debugPrint( + ' 🔍 [Echo] Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', + ); if (payloadType != 0x05) { debugPrint(' âš ī¸ [Echo] Not GRP_TXT, ignoring'); return; // Only track GRP_TXT @@ -568,10 +610,13 @@ class BleResponseHandler { // Extract path for unique echo tracking final path = rawPacket.sublist(2, 2 + pathLen); - final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final pathSignature = path + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); // Check if our node hash is in the path (meaning this is our message being rebroadcast) - final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!); + final containsOurHash = + _ourNodeHash != null && path.contains(_ourNodeHash!); if (!containsOurHash) { // This packet doesn't have our hash in the path, so it's not our message return; @@ -599,9 +644,16 @@ class BleResponseHandler { debugPrint(' Unique paths: ${tracker.uniqueEchoPaths.length}'); // Notify callback - onMessageEchoDetected?.call(tracker.messageId, tracker.echoCount, snrRaw, rssiDbm); + onMessageEchoDetected?.call( + tracker.messageId, + tracker.echoCount, + snrRaw, + rssiDbm, + ); } else { - debugPrint(' â™ģī¸ [Echo] Duplicate path (already counted): $pathSignature'); + debugPrint( + ' â™ģī¸ [Echo] Duplicate path (already counted): $pathSignature', + ); } } @@ -631,7 +683,9 @@ class BleResponseHandler { // Store by message ID temporarily _sentMessageTrackers[messageId] = tracker; - debugPrint(' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)'); + debugPrint( + ' 📤 [Echo] Tracking message $messageId (will match any GRP_TXT within 10000ms)', + ); debugPrint(' 📊 [Echo] Total trackers: ${_sentMessageTrackers.length}'); // Cleanup if too many trackers @@ -649,8 +703,12 @@ class BleResponseHandler { /// Set our node hash for packet identification void setOurNodeHash(int nodeHash) { _ourNodeHash = nodeHash; - debugPrint(' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}'); - debugPrint(' â„šī¸ [Echo] Will track packets containing our hash in the path'); + debugPrint( + ' 🔑 [Echo] Our node hash set to: 0x${nodeHash.toRadixString(16).padLeft(2, '0')}', + ); + debugPrint( + ' â„šī¸ [Echo] Will track packets containing our hash in the path', + ); } /// Associate a captured packet with a sent message @@ -666,7 +724,9 @@ class BleResponseHandler { /// [3+] = rest of path + encrypted payload void _associatePacketWithSentMessage(Uint8List rawPacket) { try { - debugPrint(' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}'); + debugPrint( + ' 🔍 [Echo] _associatePacketWithSentMessage called, packet size: ${rawPacket.length}', + ); // Need at least 3 bytes: header + path_len + first path byte if (rawPacket.length < 3) { @@ -677,8 +737,11 @@ class BleResponseHandler { // Check if this is a GRP_TXT packet (payload type = 0x05) final header = rawPacket[0]; final payloadType = (header >> 2) & 0x0F; - debugPrint(' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}'); - if (payloadType != 0x05) { // Not a group message + debugPrint( + ' 🔍 [Echo] Association check - Payload type: 0x${payloadType.toRadixString(16).padLeft(2, '0')}', + ); + if (payloadType != 0x05) { + // Not a group message debugPrint(' âš ī¸ [Echo] Not GRP_TXT, skipping association'); return; } @@ -694,16 +757,21 @@ class BleResponseHandler { // Extract the path from the packet for unique echo tracking final path = rawPacket.sublist(2, 2 + pathLen); - final pathSignature = path.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final pathSignature = path + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(':'); // Check if our node hash is in the path (meaning this is our message being rebroadcast) - final containsOurHash = _ourNodeHash != null && path.contains(_ourNodeHash!); + final containsOurHash = + _ourNodeHash != null && path.contains(_ourNodeHash!); if (!containsOurHash) { // This packet doesn't have our hash in the path, so it's not our message return; } - debugPrint(' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature'); + debugPrint( + ' ✅ [Echo] Packet contains our hash (0x${_ourNodeHash!.toRadixString(16).padLeft(2, '0')}) in path: $pathSignature', + ); // Extract encrypted payload (everything after path) final payloadStart = 2 + pathLen; @@ -754,13 +822,17 @@ class BleResponseHandler { /// Remove expired trackers void _cleanupExpiredTrackers() { - final expiredCount = _sentMessageTrackers.values.where((t) => t.isExpired).length; + final expiredCount = _sentMessageTrackers.values + .where((t) => t.isExpired) + .length; if (expiredCount > 0) { debugPrint(' 🧹 [Echo] Cleaning up $expiredCount expired tracker(s)'); } _sentMessageTrackers.removeWhere((key, tracker) { if (tracker.isExpired && tracker.packetHashHex == 'pending') { - debugPrint(' âąī¸ [Echo] Tracker expired without capturing: ${tracker.messageId}'); + debugPrint( + ' âąī¸ [Echo] Tracker expired without capturing: ${tracker.messageId}', + ); } return tracker.isExpired; }); @@ -774,7 +846,9 @@ class BleResponseHandler { final sortedEntries = _sentMessageTrackers.entries.toList() ..sort((a, b) => a.value.sentTime.compareTo(b.value.sentTime)); - final toRemove = sortedEntries.take(_sentMessageTrackers.length - _maxTrackers); + final toRemove = sortedEntries.take( + _sentMessageTrackers.length - _maxTrackers, + ); for (final entry in toRemove) { _sentMessageTrackers.remove(entry.key); } @@ -787,7 +861,9 @@ class BleResponseHandler { try { final contact = FrameParser.parseContact(reader); debugPrint(' ✅ [NewAdvert] Parsed successfully: ${contact.advName}'); - debugPrint(' outPathLen: ${contact.outPathLen} (${contact.pathDescription})'); + debugPrint( + ' outPathLen: ${contact.outPathLen} (${contact.pathDescription})', + ); onContactReceived?.call(contact); } catch (e) { debugPrint(' ❌ [NewAdvert] Parsing error: $e'); @@ -937,7 +1013,7 @@ class BleResponseHandler { final channelIdx = info['channelIdx'] as int; final channelName = info['channelName'] as String; - debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "${channelName}"'); + debugPrint(' ✅ [ChannelInfo] Channel $channelIdx: "$channelName"'); onChannelInfoReceived?.call(channelIdx, channelName); } } catch (e) { @@ -956,14 +1032,17 @@ class BleResponseHandler { // Complete any pending ACK command with error _commandQueue?.completeCommandWithError( - MeshCoreConstants.respOk, // Command was expecting OK, got ERR + MeshCoreConstants.respOk, // Command was expecting OK, got ERR errorMsg, errorCode: errorCode, ); // Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio - if (errorCode == 2) { // ERR_CODE_NOT_FOUND - debugPrint(' âš ī¸ [Error] Contact not found in radio - attempting auto-recovery'); + if (errorCode == 2) { + // ERR_CODE_NOT_FOUND + debugPrint( + ' âš ī¸ [Error] Contact not found in radio - attempting auto-recovery', + ); onContactNotFound?.call(_lastContactPublicKey); } @@ -980,14 +1059,20 @@ class BleResponseHandler { } /// Log a packet - void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { - _packetLogs.add(BlePacketLog( - timestamp: DateTime.now(), - rawData: data, - direction: direction, - responseCode: responseCode, - description: _getPacketDescription(responseCode), - )); + void _logPacket( + Uint8List data, + PacketDirection direction, { + int? responseCode, + }) { + _packetLogs.add( + BlePacketLog( + timestamp: DateTime.now(), + rawData: data, + direction: direction, + responseCode: responseCode, + description: _getPacketDescription(responseCode), + ), + ); if (_packetLogs.length > _maxLogSize) { _packetLogs.removeAt(0); diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 4cc3668..e17532a 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -12,7 +12,9 @@ class CayenneLppParser { static ContactTelemetry parse(Uint8List data) { debugPrint(' [CayenneLPP] Parsing LPP data...'); debugPrint(' Data length: ${data.length} bytes'); - debugPrint(' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); + debugPrint( + ' Data (hex): ${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); final reader = BufferReader(data); @@ -28,13 +30,17 @@ class CayenneLppParser { while (reader.hasRemaining) { try { fieldCount++; - debugPrint(' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}'); + debugPrint( + ' [Field $fieldCount] Position: ${data.length - reader.remainingBytesCount}', + ); final channel = reader.readByte(); debugPrint(' Channel: $channel'); final type = reader.readByte(); - debugPrint(' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})'); + debugPrint( + ' Type: $type (0x${type.toRadixString(16).padLeft(2, '0')})', + ); switch (type) { case MeshCoreConstants.lppDigitalInput: @@ -59,7 +65,9 @@ class CayenneLppParser { if (channel == 0 || channel == 1) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); - debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)'); + debugPrint( + ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); } break; @@ -87,14 +95,16 @@ class CayenneLppParser { final rawValue = reader.readInt16BE(); temperature = rawValue / 10.0; debugPrint(' Temperature (raw): $rawValue'); - debugPrint(' Temperature: ${temperature?.toStringAsFixed(1)}°C'); + debugPrint( + ' Temperature: ${temperature.toStringAsFixed(1)}°C', + ); break; case MeshCoreConstants.lppHumiditySensor: final rawValue = reader.readByte(); humidity = rawValue / 2.0; debugPrint(' Humidity (raw): $rawValue'); - debugPrint(' Humidity: ${humidity?.toStringAsFixed(1)}%'); + debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%'); break; case MeshCoreConstants.lppAccelerometer: @@ -102,14 +112,18 @@ class CayenneLppParser { final y = reader.readInt16BE() / 1000.0; final z = reader.readInt16BE() / 1000.0; debugPrint(' Accelerometer: x=$x, y=$y, z=$z'); - extraSensorData['accelerometer_$channel'] = {'x': x, 'y': y, 'z': z}; + extraSensorData['accelerometer_$channel'] = { + 'x': x, + 'y': y, + 'z': z, + }; break; case MeshCoreConstants.lppBarometer: final rawValue = reader.readUInt16BE(); pressure = rawValue / 10.0; debugPrint(' Barometer (raw): $rawValue'); - debugPrint(' Barometer: ${pressure?.toStringAsFixed(1)} hPa'); + debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa'); break; case MeshCoreConstants.lppVoltageSensor: @@ -120,7 +134,9 @@ class CayenneLppParser { // Treat voltage sensor as battery reading batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); - debugPrint(' → Battery: ${batteryPercentage?.toStringAsFixed(1)}% (${batteryMilliVolts?.toStringAsFixed(0)}mV)'); + debugPrint( + ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); break; case MeshCoreConstants.lppGyrometer: @@ -138,14 +154,18 @@ class CayenneLppParser { final lat = rawLat / 1000000.0; final lon = rawLon / 1000000.0; final alt = rawAlt / 100.0; - debugPrint(' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt'); - debugPrint(' GPS Location: ${lat}°, ${lon}°, altitude=${alt}m'); + debugPrint( + ' GPS Location (raw): lat=$rawLat, lon=$rawLon, alt=$rawAlt', + ); + debugPrint(' GPS Location: $lat°, $lon°, altitude=${alt}m'); gpsLocation = LatLng(lat, lon); extraSensorData['altitude_$channel'] = alt; break; default: - debugPrint(' âš ī¸ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes'); + debugPrint( + ' âš ī¸ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', + ); // Unknown type, skip remaining to avoid parsing errors reader.skip(reader.remainingBytesCount); break; @@ -159,9 +179,15 @@ class CayenneLppParser { debugPrint(' Parsed $fieldCount fields'); debugPrint(' ✅ [CayenneLPP] Parsing complete'); - debugPrint(' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}'); - debugPrint(' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}'); - debugPrint(' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}'); + debugPrint( + ' GPS: ${gpsLocation != null ? '${gpsLocation.latitude}°, ${gpsLocation.longitude}°' : 'none'}', + ); + debugPrint( + ' Battery: ${batteryPercentage != null ? '${batteryPercentage.toStringAsFixed(1)}%' : 'none'}', + ); + debugPrint( + ' Temperature: ${temperature != null ? '${temperature.toStringAsFixed(1)}°C' : 'none'}', + ); // IMPORTANT: Cayenne LPP format does NOT include a timestamp field. // We use DateTime.now() as the timestamp, which represents when the data @@ -173,7 +199,9 @@ class CayenneLppParser { // - The actual age of the telemetry data cannot be determined from the LPP format // - Devices may cache telemetry for hours and send it later when requested final parseTimestamp = DateTime.now(); - debugPrint(' Timestamp: $parseTimestamp (parse time, NOT device collection time)'); + debugPrint( + ' Timestamp: $parseTimestamp (parse time, NOT device collection time)', + ); return ContactTelemetry( gpsLocation: gpsLocation, diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart index f12d7f7..58d063a 100644 --- a/lib/services/contact_storage_service.dart +++ b/lib/services/contact_storage_service.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; @@ -17,7 +16,9 @@ class ContactStorageService { final prefs = await SharedPreferences.getInstance(); // Convert contacts to JSON - final jsonList = contacts.map((contact) => _contactToJson(contact)).toList(); + final jsonList = contacts + .map((contact) => _contactToJson(contact)) + .toList(); // Limit to max stored contacts (keep most recent) final limitedList = jsonList.length > _maxStoredContacts @@ -27,7 +28,9 @@ class ContactStorageService { final jsonString = jsonEncode(limitedList); await prefs.setString(_contactsKey, jsonString); - debugPrint('✅ [ContactStorage] Saved ${limitedList.length} contacts to storage'); + debugPrint( + '✅ [ContactStorage] Saved ${limitedList.length} contacts to storage', + ); } catch (e) { debugPrint('❌ [ContactStorage] Error saving contacts: $e'); } @@ -55,16 +58,23 @@ class ContactStorageService { // Filter out contacts with the excluded public key final filteredContacts = excludePublicKey != null ? contacts.where((contact) { - final matches = _publicKeysMatch(contact.publicKey, excludePublicKey); + final matches = _publicKeysMatch( + contact.publicKey, + excludePublicKey, + ); if (matches) { - debugPrint('â„šī¸ [ContactStorage] Excluding contact with matching public key: ${contact.advName}'); + debugPrint( + 'â„šī¸ [ContactStorage] Excluding contact with matching public key: ${contact.advName}', + ); } return !matches; }).toList() : contacts; - debugPrint('✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage' - '${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}'); + debugPrint( + '✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage' + '${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}', + ); return filteredContacts; } catch (e) { debugPrint('❌ [ContactStorage] Error loading contacts: $e'); @@ -99,11 +109,7 @@ class ContactStorageService { final jsonString = prefs.getString(_contactsKey); if (jsonString == null || jsonString.isEmpty) { - return { - 'contactCount': 0, - 'storageSizeBytes': 0, - 'storageSizeKB': 0, - }; + return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; } final sizeBytes = jsonString.length; @@ -116,11 +122,7 @@ class ContactStorageService { }; } catch (e) { debugPrint('❌ [ContactStorage] Error getting storage stats: $e'); - return { - 'contactCount': 0, - 'storageSizeBytes': 0, - 'storageSizeKB': 0, - }; + return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; } } @@ -137,7 +139,9 @@ class ContactStorageService { 'advLat': contact.advLat, 'advLon': contact.advLon, 'lastMod': contact.lastMod, - 'telemetry': contact.telemetry != null ? _telemetryToJson(contact.telemetry!) : null, + 'telemetry': contact.telemetry != null + ? _telemetryToJson(contact.telemetry!) + : null, }; } @@ -145,7 +149,9 @@ class ContactStorageService { Contact? _contactFromJson(Map json) { try { return Contact( - publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)), + publicKey: Uint8List.fromList( + base64Decode(json['publicKey'] as String), + ), type: ContactType.fromValue(json['type'] as int), flags: json['flags'] as int, outPathLen: json['outPathLen'] as int, @@ -200,7 +206,8 @@ class ContactStorageService { humidity: json['humidity'] as double?, pressure: json['pressure'] as double?, timestamp: DateTime.fromMillisecondsSinceEpoch( - json['timestampMillis'] as int), + json['timestampMillis'] as int, + ), extraSensorData: json['extraSensorData'] as Map?, ); } catch (e) { diff --git a/lib/services/mbtiles_service.dart b/lib/services/mbtiles_service.dart index a288dd4..c2f678c 100644 --- a/lib/services/mbtiles_service.dart +++ b/lib/services/mbtiles_service.dart @@ -129,7 +129,7 @@ class MbtilesService { final mbtiles = MbTiles(mbtilesPath: file.path); // Get metadata from MBTiles - final metadata = await mbtiles.getMetadata(); + final metadata = mbtiles.getMetadata(); // Convert bounds object to string if available String? boundsStr; @@ -222,7 +222,7 @@ class MbtilesService { final mbtiles = MbTiles(mbtilesPath: file.path); // Try to get metadata to check for compression hints - final metadata = await mbtiles.getMetadata(); + final metadata = mbtiles.getMetadata(); final format = metadata.format; // For Geofabrik files, format is 'pbf' and data is gzipped diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 8f53b97..381355b 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import '../models/contact.dart'; @@ -15,26 +14,41 @@ import 'meshcore_constants.dart'; typedef OnContactCallback = void Function(Contact contact); typedef OnContactsCompleteCallback = void Function(List contacts); typedef OnMessageCallback = void Function(Message message); -typedef OnTelemetryCallback = void Function(Uint8List publicKey, Uint8List lppData); +typedef OnTelemetryCallback = + void Function(Uint8List publicKey, Uint8List lppData); typedef OnSelfInfoCallback = void Function(Map selfInfo); typedef OnDeviceInfoCallback = void Function(Map deviceInfo); typedef OnNoMoreMessagesCallback = void Function(); typedef OnMessageWaitingCallback = void Function(); -typedef OnLoginSuccessCallback = void Function(Uint8List publicKeyPrefix, int permissions, bool isAdmin, int tag); +typedef OnLoginSuccessCallback = + void Function( + Uint8List publicKeyPrefix, + int permissions, + bool isAdmin, + int tag, + ); typedef OnLoginFailCallback = void Function(Uint8List publicKeyPrefix); typedef OnAdvertReceivedCallback = void Function(Uint8List publicKey); typedef OnPathUpdatedCallback = void Function(Uint8List publicKey); -typedef OnMessageSentCallback = void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode); -typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTimeMs); -typedef OnMessageEchoDetectedCallback = void Function(String messageId, int echoCount, int snrRaw, int rssiDbm); -typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); -typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); -typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); +typedef OnMessageSentCallback = + void Function(int expectedAckTag, int suggestedTimeoutMs, bool isFloodMode); +typedef OnMessageDeliveredCallback = + void Function(int ackCode, int roundTripTimeMs); +typedef OnMessageEchoDetectedCallback = + void Function(String messageId, int echoCount, int snrRaw, int rssiDbm); +typedef OnStatusResponseCallback = + void Function(Uint8List publicKeyPrefix, Uint8List statusData); +typedef OnBinaryResponseCallback = + void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); +typedef OnBatteryAndStorageCallback = + void Function(int millivolts, int? usedKb, int? totalKb); typedef OnErrorCallback = void Function(String error, {int? errorCode}); typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); -typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName); +typedef OnChannelInfoCallback = + void Function(int channelIdx, String channelName); typedef OnConnectionStateCallback = void Function(bool isConnected); -typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts); +typedef OnReconnectionAttemptCallback = + void Function(int attemptNumber, int maxAttempts); typedef OnRssiUpdateCallback = void Function(int rssi); /// MeshCore BLE Service - coordinates BLE communication components @@ -89,7 +103,9 @@ class MeshCoreBleService { onError?.call(error); }; _connectionManager.onReconnectionAttempt = (attemptNumber, maxAttempts) { - debugPrint('🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts'); + debugPrint( + '🔄 [Service] Reconnection attempt $attemptNumber/$maxAttempts', + ); onReconnectionAttempt?.call(attemptNumber, maxAttempts); }; _connectionManager.onRssiUpdate = (rssi) { @@ -136,9 +152,10 @@ class MeshCoreBleService { _responseHandler.onMessageWaiting = () { onMessageWaiting?.call(); }; - _responseHandler.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) { - onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); - }; + _responseHandler.onLoginSuccess = + (publicKeyPrefix, permissions, isAdmin, tag) { + onLoginSuccess?.call(publicKeyPrefix, permissions, isAdmin, tag); + }; _responseHandler.onLoginFail = (publicKeyPrefix) { onLoginFail?.call(publicKeyPrefix); }; @@ -148,15 +165,17 @@ class MeshCoreBleService { _responseHandler.onPathUpdated = (publicKey) { onPathUpdated?.call(publicKey); }; - _responseHandler.onMessageSent = (expectedAckTag, suggestedTimeoutMs, isFloodMode) { - onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode); - }; + _responseHandler.onMessageSent = + (expectedAckTag, suggestedTimeoutMs, isFloodMode) { + onMessageSent?.call(expectedAckTag, suggestedTimeoutMs, isFloodMode); + }; _responseHandler.onMessageDelivered = (ackCode, roundTripTimeMs) { onMessageDelivered?.call(ackCode, roundTripTimeMs); }; - _responseHandler.onMessageEchoDetected = (messageId, echoCount, snrRaw, rssiDbm) { - onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm); - }; + _responseHandler.onMessageEchoDetected = + (messageId, echoCount, snrRaw, rssiDbm) { + onMessageEchoDetected?.call(messageId, echoCount, snrRaw, rssiDbm); + }; _responseHandler.onStatusResponse = (publicKeyPrefix, statusData) { onStatusResponse?.call(publicKeyPrefix, statusData); }; @@ -189,13 +208,18 @@ class MeshCoreBleService { int get txPacketCount => _commandSender.txPacketCount; List get packetLogs { // Merge logs from both sender and handler - final allLogs = [..._commandSender.packetLogs, ..._responseHandler.packetLogs]; + final allLogs = [ + ..._commandSender.packetLogs, + ..._responseHandler.packetLogs, + ]; allLogs.sort((a, b) => a.timestamp.compareTo(b.timestamp)); return allLogs; } /// Scan for MeshCore devices - Stream scanForDevices({Duration timeout = const Duration(seconds: 10)}) { + Stream scanForDevices({ + Duration timeout = const Duration(seconds: 10), + }) { return _connectionManager.scanForDevices(timeout: timeout); } @@ -212,7 +236,9 @@ class MeshCoreBleService { // Setup response handler with TX characteristic if (_connectionManager.txCharacteristic != null) { - _responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!); + _responseHandler.subscribeToNotifications( + _connectionManager.txCharacteristic!, + ); } // Send initial device query and wait for responses @@ -240,20 +266,26 @@ class MeshCoreBleService { Future _sendDeviceQuery() async { // STEP 1: Send device query FIRST to get device capabilities // This is the first command to send per protocol documentation - debugPrint('🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...'); - final deviceInfo = await _commandSender.writeDataAndWaitForResponse>( - FrameBuilder.buildDeviceQuery(), - MeshCoreConstants.respDeviceInfo, + debugPrint( + '🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...', + ); + final deviceInfo = await _commandSender + .writeDataAndWaitForResponse>( + FrameBuilder.buildDeviceQuery(), + MeshCoreConstants.respDeviceInfo, + ); + debugPrint( + '✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}', ); - debugPrint('✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}'); // STEP 2: Send app start to initialize the app session // This is the first command after connection per protocol documentation debugPrint('🚀 [Service] Sending app start (CMD_APP_START)...'); - final selfInfo = await _commandSender.writeDataAndWaitForResponse>( - FrameBuilder.buildAppStart(), - MeshCoreConstants.respSelfInfo, - ); + final selfInfo = await _commandSender + .writeDataAndWaitForResponse>( + FrameBuilder.buildAppStart(), + MeshCoreConstants.respSelfInfo, + ); debugPrint('✅ [Service] Self info received: node initialized'); // STEP 3: Set device clock AFTER initialization @@ -278,7 +310,9 @@ class MeshCoreBleService { Future addOrUpdateContact(Contact contact) async { debugPrint('📝 [BLE] Adding/updating contact on companion radio:'); debugPrint(' Name: ${contact.advName}'); - debugPrint(' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + ' Public key prefix: ${contact.publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); debugPrint(' Type: ${contact.type} (${contact.type.value})'); await _commandSender.writeData(FrameBuilder.buildAddUpdateContact(contact)); @@ -300,18 +334,22 @@ class MeshCoreBleService { // Track the last contact for auto-recovery if contact not found _responseHandler.setLastContactPublicKey(contactPublicKey); - await _commandSender.writeData(FrameBuilder.buildSendTxtMsg( - contactPublicKey: contactPublicKey, - text: text, - textType: textType, - attempt: attempt, - )); + await _commandSender.writeData( + FrameBuilder.buildSendTxtMsg( + contactPublicKey: contactPublicKey, + text: text, + textType: textType, + attempt: attempt, + ), + ); } /// Send flood-mode text message to channel /// Track a sent channel message for echo detection void trackSentChannelMessage(String messageId) { - debugPrint('đŸ”ĩ [MeshCoreBleService] trackSentChannelMessage called for: $messageId'); + debugPrint( + 'đŸ”ĩ [MeshCoreBleService] trackSentChannelMessage called for: $messageId', + ); _responseHandler.trackSentMessage(messageId, null); } @@ -333,20 +371,24 @@ class MeshCoreBleService { // Channel messages use fire-and-forget (no ACK expected) // The firmware responds with RESP_CODE_OK but we don't wait for it - await _commandSender.writeData(FrameBuilder.buildSendChannelTxtMsg( - channelIdx: channelIdx, - text: text, - textType: textType, - )); + await _commandSender.writeData( + FrameBuilder.buildSendChannelTxtMsg( + channelIdx: channelIdx, + text: text, + textType: textType, + ), + ); } /// Request telemetry from contact (deprecated) @Deprecated('Use sendBinaryRequest() instead for better functionality') - Future requestTelemetry(Uint8List contactPublicKey, {bool zeroHop = false}) async { - await _commandSender.writeData(FrameBuilder.buildSendTelemetryReq( - contactPublicKey, - zeroHop: zeroHop, - )); + Future requestTelemetry( + Uint8List contactPublicKey, { + bool zeroHop = false, + }) async { + await _commandSender.writeData( + FrameBuilder.buildSendTelemetryReq(contactPublicKey, zeroHop: zeroHop), + ); } /// Send binary request to contact @@ -354,10 +396,12 @@ class MeshCoreBleService { required Uint8List contactPublicKey, required Uint8List requestData, }) async { - await _commandSender.writeData(FrameBuilder.buildSendBinaryReq( - contactPublicKey: contactPublicKey, - requestData: requestData, - )); + await _commandSender.writeData( + FrameBuilder.buildSendBinaryReq( + contactPublicKey: contactPublicKey, + requestData: requestData, + ), + ); } /// Get battery voltage and storage information @@ -388,12 +432,16 @@ class MeshCoreBleService { /// Send self advertisement packet to mesh network Future sendSelfAdvert({bool floodMode = true}) async { - await _commandSender.writeData(FrameBuilder.buildSendSelfAdvert(floodMode: floodMode)); + await _commandSender.writeData( + FrameBuilder.buildSendSelfAdvert(floodMode: floodMode), + ); } /// Set advertised name Future setAdvertName(String name) async { - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertName(name)); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetAdvertName(name), + ); } /// Set advertised latitude and longitude @@ -402,10 +450,12 @@ class MeshCoreBleService { required double longitude, }) async { // This command returns OK (0x00) response, so wait for acknowledgment - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon( - latitude: latitude, - longitude: longitude, - )); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetAdvertLatLon( + latitude: latitude, + longitude: longitude, + ), + ); } /// Set radio parameters @@ -415,17 +465,21 @@ class MeshCoreBleService { required int spreadingFactor, required int codingRate, }) async { - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetRadioParams( - frequency: frequency, - bandwidth: bandwidth, - spreadingFactor: spreadingFactor, - codingRate: codingRate, - )); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetRadioParams( + frequency: frequency, + bandwidth: bandwidth, + spreadingFactor: spreadingFactor, + codingRate: codingRate, + ), + ); } /// Set transmit power Future setTxPower(int powerDbm) async { - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetTxPower(powerDbm)); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetTxPower(powerDbm), + ); } /// Set other parameters @@ -435,12 +489,14 @@ class MeshCoreBleService { required int advertLocationPolicy, int multiAcks = 0, }) async { - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetOtherParams( - manualAddContacts: manualAddContacts, - telemetryModes: telemetryModes, - advertLocationPolicy: advertLocationPolicy, - multiAcks: multiAcks, - )); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetOtherParams( + manualAddContacts: manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: advertLocationPolicy, + multiAcks: multiAcks, + ), + ); } /// Send login request to room or repeater @@ -453,37 +509,55 @@ class MeshCoreBleService { } debugPrint('🔐 [BLE] Preparing login request:'); - debugPrint(' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); - debugPrint(' Password: ${"*" * password.length} (${password.length} chars)'); + debugPrint( + ' Room public key prefix: ${roomPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); + debugPrint( + ' Password: ${"*" * password.length} (${password.length} chars)', + ); - await _commandSender.writeData(FrameBuilder.buildSendLogin( - roomPublicKey: roomPublicKey, - password: password, - )); + await _commandSender.writeData( + FrameBuilder.buildSendLogin( + roomPublicKey: roomPublicKey, + password: password, + ), + ); } /// Send status request to repeater or sensor node Future sendStatusRequest(Uint8List contactPublicKey) async { debugPrint('📊 [BLE] Preparing status request:'); - debugPrint(' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + ' Target node public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); - await _commandSender.writeData(FrameBuilder.buildSendStatusReq(contactPublicKey)); + await _commandSender.writeData( + FrameBuilder.buildSendStatusReq(contactPublicKey), + ); } /// Reset path for a contact - forces next message to flood and re-learn route Future resetPath(Uint8List contactPublicKey) async { debugPrint('🔄 [BLE] Resetting path for contact:'); - debugPrint(' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + ' Contact public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); - await _commandSender.writeData(FrameBuilder.buildResetPath(contactPublicKey)); + await _commandSender.writeData( + FrameBuilder.buildResetPath(contactPublicKey), + ); } /// Remove a contact from the companion radio Future removeContact(Uint8List contactPublicKey) async { debugPrint('đŸ—‘ī¸ [BLE] Removing contact from companion radio:'); - debugPrint(' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + ' Public key prefix: ${contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); - await _commandSender.writeData(FrameBuilder.buildRemoveContact(contactPublicKey)); + await _commandSender.writeData( + FrameBuilder.buildRemoveContact(contactPublicKey), + ); debugPrint('✅ [BLE] CMD_REMOVE_CONTACT sent'); } @@ -506,11 +580,13 @@ class MeshCoreBleService { debugPrint(' Channel name: $channelName'); debugPrint(' Secret length: ${secret.length} bytes'); - await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetChannel( - channelIdx: channelIdx, - channelName: channelName, - secret: secret, - )); + await _commandSender.writeDataAndWaitForAck( + FrameBuilder.buildSetChannel( + channelIdx: channelIdx, + channelName: channelName, + secret: secret, + ), + ); debugPrint('✅ [BLE] CMD_SET_CHANNEL sent successfully'); } diff --git a/lib/services/message_storage_service.dart b/lib/services/message_storage_service.dart index a643af4..b8fd081 100644 --- a/lib/services/message_storage_service.dart +++ b/lib/services/message_storage_service.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/message.dart'; @@ -27,7 +26,9 @@ class MessageStorageService { final jsonString = jsonEncode(limitedList); await prefs.setString(_messagesKey, jsonString); - debugPrint('✅ [MessageStorage] Saved ${limitedList.length} messages to storage'); + debugPrint( + '✅ [MessageStorage] Saved ${limitedList.length} messages to storage', + ); } catch (e) { debugPrint('❌ [MessageStorage] Error saving messages: $e'); } @@ -51,7 +52,9 @@ class MessageStorageService { .cast() .toList(); - debugPrint('✅ [MessageStorage] Loaded ${messages.length} messages from storage'); + debugPrint( + '✅ [MessageStorage] Loaded ${messages.length} messages from storage', + ); return messages; } catch (e) { debugPrint('❌ [MessageStorage] Error loading messages: $e'); @@ -77,11 +80,7 @@ class MessageStorageService { final jsonString = prefs.getString(_messagesKey); if (jsonString == null || jsonString.isEmpty) { - return { - 'messageCount': 0, - 'storageSizeBytes': 0, - 'storageSizeKB': 0, - }; + return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; } final sizeBytes = jsonString.length; @@ -94,11 +93,7 @@ class MessageStorageService { }; } catch (e) { debugPrint('❌ [MessageStorage] Error getting storage stats: $e'); - return { - 'messageCount': 0, - 'storageSizeBytes': 0, - 'storageSizeKB': 0, - }; + return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0}; } } @@ -144,7 +139,8 @@ class MessageStorageService { ), senderPublicKeyPrefix: json['senderPublicKeyPrefix'] != null ? Uint8List.fromList( - base64Decode(json['senderPublicKeyPrefix'] as String)) + base64Decode(json['senderPublicKeyPrefix'] as String), + ) : null, channelIdx: json['channelIdx'] as int?, pathLen: json['pathLen'] as int, @@ -158,12 +154,13 @@ class MessageStorageService { orElse: () => SarMarkerType.unknown, ) : null, - sarGpsCoordinates: json['sarGpsLat'] != null && - json['sarGpsLon'] != null + sarGpsCoordinates: + json['sarGpsLat'] != null && json['sarGpsLon'] != null ? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double) : null, receivedAt: DateTime.fromMillisecondsSinceEpoch( - json['receivedAtMillis'] as int), + json['receivedAtMillis'] as int, + ), senderName: json['senderName'] as String?, deliveryStatus: json['deliveryStatus'] != null ? MessageDeliveryStatus.values.firstWhere( @@ -176,11 +173,13 @@ class MessageStorageService { roundTripTimeMs: json['roundTripTimeMs'] as int?, deliveredAt: json['deliveredAtMillis'] != null ? DateTime.fromMillisecondsSinceEpoch( - json['deliveredAtMillis'] as int) + json['deliveredAtMillis'] as int, + ) : null, recipientPublicKey: json['recipientPublicKey'] != null ? Uint8List.fromList( - base64Decode(json['recipientPublicKey'] as String)) + base64Decode(json['recipientPublicKey'] as String), + ) : null, isRead: json['isRead'] as bool? ?? false, ); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 9dbd361..c670ec5 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:timezone/timezone.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz; import '../models/sar_marker.dart'; import '../l10n/app_localizations.dart'; @@ -44,7 +43,9 @@ class NotificationService { tz.initializeTimeZones(); // Android initialization settings - const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); // iOS initialization settings final darwinSettings = DarwinInitializationSettings( @@ -85,25 +86,34 @@ class NotificationService { try { // iOS permissions final iosPlugin = _notificationsPlugin - .resolvePlatformSpecificImplementation(); + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); if (iosPlugin != null) { final granted = await iosPlugin.requestPermissions( alert: true, badge: true, sound: true, - critical: true, // Request critical alert permission for urgent SAR notifications + critical: + true, // Request critical alert permission for urgent SAR notifications ); _permissionGranted = granted ?? false; - debugPrint('📱 [NotificationService] iOS permissions granted: $_permissionGranted'); + debugPrint( + '📱 [NotificationService] iOS permissions granted: $_permissionGranted', + ); } // Android 13+ permissions final androidPlugin = _notificationsPlugin - .resolvePlatformSpecificImplementation(); + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); if (androidPlugin != null) { final granted = await androidPlugin.requestNotificationsPermission(); _permissionGranted = granted ?? false; - debugPrint('🤖 [NotificationService] Android permissions granted: $_permissionGranted'); + debugPrint( + '🤖 [NotificationService] Android permissions granted: $_permissionGranted', + ); } } catch (e) { debugPrint('âš ī¸ [NotificationService] Error requesting permissions: $e'); @@ -114,7 +124,9 @@ class NotificationService { Future _createNotificationChannels() async { try { final androidPlugin = _notificationsPlugin - .resolvePlatformSpecificImplementation(); + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); if (androidPlugin == null) return; @@ -152,7 +164,9 @@ class NotificationService { /// Handle notification tap (foreground) void _onNotificationResponse(NotificationResponse response) { - debugPrint('🔔 [NotificationService] Notification tapped: ${response.payload}'); + debugPrint( + '🔔 [NotificationService] Notification tapped: ${response.payload}', + ); // TODO: Navigate to map tab and show SAR marker // This would require a callback to the app layer } @@ -166,18 +180,23 @@ class NotificationService { AppLocalizations? localizations, }) async { if (!_isInitialized) { - debugPrint('âš ī¸ [NotificationService] Not initialized, skipping notification'); + debugPrint( + 'âš ī¸ [NotificationService] Not initialized, skipping notification', + ); return; } if (!_permissionGranted) { - debugPrint('âš ī¸ [NotificationService] Permission not granted, skipping notification'); + debugPrint( + 'âš ī¸ [NotificationService] Permission not granted, skipping notification', + ); return; } try { // Generate unique notification ID based on timestamp - final notificationId = _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); + final notificationId = + _sarNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); // Build notification title and body final title = _buildNotificationTitle(type, localizations); @@ -222,7 +241,8 @@ class NotificationService { badgeNumber: 1, threadIdentifier: 'sar_markers', categoryIdentifier: 'SAR_ALERT', - interruptionLevel: InterruptionLevel.critical, // Critical alert (bypasses silent mode) + interruptionLevel: + InterruptionLevel.critical, // Critical alert (bypasses silent mode) ); // Combined notification details @@ -250,7 +270,10 @@ class NotificationService { } /// Build notification title based on SAR marker type - String _buildNotificationTitle(SarMarkerType type, AppLocalizations? localizations) { + String _buildNotificationTitle( + SarMarkerType type, + AppLocalizations? localizations, + ) { if (localizations == null) { return '🚨 ${type.displayName} Detected'; } @@ -330,27 +353,33 @@ class NotificationService { AppLocalizations? localizations, }) async { if (!_isInitialized) { - debugPrint('âš ī¸ [NotificationService] Not initialized, skipping notification'); + debugPrint( + 'âš ī¸ [NotificationService] Not initialized, skipping notification', + ); return; } if (!_permissionGranted) { - debugPrint('âš ī¸ [NotificationService] Permission not granted, skipping notification'); + debugPrint( + 'âš ī¸ [NotificationService] Permission not granted, skipping notification', + ); return; } try { // Generate unique notification ID based on timestamp - final notificationId = _messageNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); + final notificationId = + _messageNotificationId + + (DateTime.now().millisecondsSinceEpoch % 1000); // Build notification title and body final title = isChannelMessage ? (localizations != null - ? '${localizations.channel}: ${channelName ?? "Public"}' - : 'Channel: ${channelName ?? "Public"}') + ? '${localizations.channel}: ${channelName ?? "Public"}' + : 'Channel: ${channelName ?? "Public"}') : (localizations != null - ? '${localizations.newMessage} ${localizations.from} $senderName' - : 'New message from $senderName'); + ? '${localizations.newMessage} ${localizations.from} $senderName' + : 'New message from $senderName'); final body = messageText.length > 200 ? '${messageText.substring(0, 200)}...' @@ -381,7 +410,9 @@ class NotificationService { presentBadge: true, presentSound: true, sound: 'default', - threadIdentifier: isChannelMessage ? 'channel_messages' : 'direct_messages', + threadIdentifier: isChannelMessage + ? 'channel_messages' + : 'direct_messages', subtitle: senderName, ); @@ -404,7 +435,9 @@ class NotificationService { debugPrint(' Sender: $senderName'); debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); } catch (e) { - debugPrint('❌ [NotificationService] Error showing message notification: $e'); + debugPrint( + '❌ [NotificationService] Error showing message notification: $e', + ); } } @@ -432,7 +465,9 @@ class NotificationService { Future areNotificationsEnabled() async { try { final androidPlugin = _notificationsPlugin - .resolvePlatformSpecificImplementation(); + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); if (androidPlugin != null) { final enabled = await androidPlugin.areNotificationsEnabled(); return enabled ?? false; @@ -441,7 +476,9 @@ class NotificationService { // For iOS, assume enabled if permission was granted return _permissionGranted; } catch (e) { - debugPrint('âš ī¸ [NotificationService] Error checking notification status: $e'); + debugPrint( + 'âš ī¸ [NotificationService] Error checking notification status: $e', + ); return false; } } @@ -451,7 +488,9 @@ class NotificationService { try { return await _notificationsPlugin.pendingNotificationRequests(); } catch (e) { - debugPrint('âš ī¸ [NotificationService] Error getting pending notifications: $e'); + debugPrint( + 'âš ī¸ [NotificationService] Error getting pending notifications: $e', + ); return []; } } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index a60cce0..cac389a 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -35,13 +35,17 @@ class ContactTile extends StatelessWidget { final appProvider = context.watch(); final isSimpleMode = appProvider.isSimpleMode; - final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent; + final hasTelemetry = + contact.telemetry != null && contact.telemetry!.isRecent; final battery = contact.displayBattery; final location = contact.displayLocation; // Calculate distance if both positions are available String? distanceText; - if (location != null && currentPosition != null && calculateDistance != null && formatDistance != null) { + if (location != null && + currentPosition != null && + calculateDistance != null && + formatDistance != null) { final distanceMeters = calculateDistance!( currentPosition!.latitude, currentPosition!.longitude, @@ -69,10 +73,7 @@ class ContactTile extends StatelessWidget { contact.roleEmoji!, style: const TextStyle(fontSize: 24), ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), + : Icon(_getTypeIcon(contact.type), color: Colors.white), ), // New contact indicator badge (top-right) if (contact.isNew) @@ -161,7 +162,9 @@ class ContactTile extends StatelessWidget { ), const SizedBox(width: 2), Text( - contact.hasPath ? AppLocalizations.of(context)!.direct : AppLocalizations.of(context)!.flood, + contact.hasPath + ? AppLocalizations.of(context)!.direct + : AppLocalizations.of(context)!.flood, style: TextStyle( fontSize: 9, fontWeight: FontWeight.w600, @@ -183,7 +186,11 @@ class ContactTile extends StatelessWidget { if (location != null) ...[ Row( children: [ - const Icon(Icons.location_on, size: 12, color: Colors.blue), + const Icon( + Icons.location_on, + size: 12, + color: Colors.blue, + ), const SizedBox(width: 4), Expanded( child: Text( @@ -198,14 +205,19 @@ class ContactTile extends StatelessWidget { const SizedBox(height: 4), Row( children: [ - const Icon(Icons.straighten, size: 12, color: Colors.blue), + const Icon( + Icons.straighten, + size: 12, + color: Colors.blue, + ), const SizedBox(width: 4), Text( '${AppLocalizations.of(context)!.distance}: $distanceText', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.blue, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -213,9 +225,9 @@ class ContactTile extends StatelessWidget { ] else Text( AppLocalizations.of(context)!.noGpsData, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), ), ], ) @@ -229,7 +241,10 @@ class ContactTile extends StatelessWidget { children: [ if (roomLoginState.isAdmin) Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.red.withOpacity(0.2), borderRadius: BorderRadius.circular(4), @@ -238,22 +253,30 @@ class ContactTile extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), + const Icon( + Icons.admin_panel_settings, + size: 10, + color: Colors.red, + ), const SizedBox(width: 2), Text( AppLocalizations.of(context)!.admin, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 10, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 10, + ), ), ], ), ), if (roomLoginState.isAdmin) const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.green.withOpacity(0.2), borderRadius: BorderRadius.circular(4), @@ -262,15 +285,20 @@ class ContactTile extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.check_circle, size: 10, color: Colors.green), + const Icon( + Icons.check_circle, + size: 10, + color: Colors.green, + ), const SizedBox(width: 2), Text( AppLocalizations.of(context)!.loggedIn, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - fontSize: 10, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + fontSize: 10, + ), ), ], ), @@ -285,7 +313,9 @@ class ContactTile extends StatelessWidget { Icon( Icons.access_time, size: 12, - color: contact.isRecentlySeen ? Colors.green : Colors.grey, + color: contact.isRecentlySeen + ? Colors.green + : Colors.grey, ), const SizedBox(width: 4), Text( @@ -297,9 +327,17 @@ class ContactTile extends StatelessWidget { const Text('â€ĸ', style: TextStyle(color: Colors.grey)), const SizedBox(width: 8), if (hasTelemetry) - const Icon(Icons.sensors, size: 12, color: Colors.green) + const Icon( + Icons.sensors, + size: 12, + color: Colors.green, + ) else - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const Icon( + Icons.sensors_off, + size: 12, + color: Colors.grey, + ), const SizedBox(width: 4), Expanded( child: Text( @@ -312,7 +350,11 @@ class ContactTile extends StatelessWidget { const SizedBox(width: 8), const Text('â€ĸ', style: TextStyle(color: Colors.grey)), const SizedBox(width: 8), - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const Icon( + Icons.sensors_off, + size: 12, + color: Colors.grey, + ), const SizedBox(width: 4), Text( AppLocalizations.of(context)!.noGpsData, @@ -326,14 +368,19 @@ class ContactTile extends StatelessWidget { const SizedBox(height: 4), Row( children: [ - const Icon(Icons.straighten, size: 12, color: Colors.blue), + const Icon( + Icons.straighten, + size: 12, + color: Colors.blue, + ), const SizedBox(width: 4), Text( '${AppLocalizations.of(context)!.distance}: $distanceText', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.blue, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -348,7 +395,9 @@ class ContactTile extends StatelessWidget { } else if (isSimpleMode && contact.type == ContactType.repeater) { // In simple mode, tapping a repeater jumps to the map _jumpToMapForRepeater(context, contact); - } else if (isSimpleMode && contact.type == ContactType.room && !contact.isPublicChannel) { + } else if (isSimpleMode && + contact.type == ContactType.room && + !contact.isPublicChannel) { _showRoomLoginDialog(context, contact); } else { _showContactDetails(context, contact); @@ -364,8 +413,12 @@ class ContactTile extends StatelessWidget { ToastLogger.info( context, hasPath - ? AppLocalizations.of(context)!.pingingDirect(contact.displayName) - : AppLocalizations.of(context)!.pingingFlood(contact.displayName), + ? AppLocalizations.of( + context, + )!.pingingDirect(contact.displayName) + : AppLocalizations.of( + context, + )!.pingingFlood(contact.displayName), ); // Use smart ping with automatic fallback @@ -377,7 +430,9 @@ class ContactTile extends StatelessWidget { if (context.mounted) { ToastLogger.warning( context, - AppLocalizations.of(context)!.directPingTimeout(contact.displayName), + AppLocalizations.of( + context, + )!.directPingTimeout(contact.displayName), ); } }, @@ -390,7 +445,9 @@ class ContactTile extends StatelessWidget { context, AppLocalizations.of(context)!.pingSuccessful( contact.displayName, - result.retriedWithFlooding ? AppLocalizations.of(context)!.viaFloodingFallback : '', + result.retriedWithFlooding + ? AppLocalizations.of(context)!.viaFloodingFallback + : '', ), ); } else { @@ -452,7 +509,9 @@ class ContactTile extends StatelessWidget { builder: (context) => AlertDialog( title: Text(AppLocalizations.of(context)!.deleteContact), content: Text( - AppLocalizations.of(context)!.deleteContactConfirmation(contact.displayName), + AppLocalizations.of( + context, + )!.deleteContactConfirmation(contact.displayName), ), actions: [ TextButton( @@ -479,7 +538,10 @@ class ContactTile extends StatelessWidget { try { // Show loading toast - ToastLogger.info(context, AppLocalizations.of(context)!.removingContact(contact.displayName)); + ToastLogger.info( + context, + AppLocalizations.of(context)!.removingContact(contact.displayName), + ); // Remove contact from provider (which will also remove from device) await contactsProvider.removeContact( @@ -492,11 +554,17 @@ class ContactTile extends StatelessWidget { ); if (context.mounted) { - ToastLogger.success(context, AppLocalizations.of(context)!.contactRemoved(contact.displayName)); + ToastLogger.success( + context, + AppLocalizations.of(context)!.contactRemoved(contact.displayName), + ); } } catch (e) { if (context.mounted) { - ToastLogger.error(context, AppLocalizations.of(context)!.failedToRemoveContact(e.toString())); + ToastLogger.error( + context, + AppLocalizations.of(context)!.failedToRemoveContact(e.toString()), + ); } } } @@ -543,10 +611,7 @@ class ContactTile extends StatelessWidget { contact.roleEmoji!, style: const TextStyle(fontSize: 24), ) - : Icon( - _getTypeIcon(contact.type), - color: Colors.white, - ), + : Icon(_getTypeIcon(contact.type), color: Colors.white), ), const SizedBox(width: 12), Expanded( @@ -572,7 +637,10 @@ class ContactTile extends StatelessWidget { controller: scrollController, padding: const EdgeInsets.all(16), children: [ - _DetailRow(AppLocalizations.of(context)!.type, contact.type.displayName), + _DetailRow( + AppLocalizations.of(context)!.type, + contact.type.displayName, + ), // Public Key with copy button Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -586,16 +654,18 @@ class ContactTile extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w500), ), ), - Expanded( - child: Text(contact.publicKeyShort), - ), + Expanded(child: Text(contact.publicKeyShort)), const SizedBox(width: 8), InkWell( onTap: () { - Clipboard.setData(ClipboardData(text: contact.publicKeyHex)); + Clipboard.setData( + ClipboardData(text: contact.publicKeyHex), + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.publicKeyCopied), + content: Text( + AppLocalizations.of(context)!.publicKeyCopied, + ), duration: const Duration(seconds: 2), ), ); @@ -613,7 +683,10 @@ class ContactTile extends StatelessWidget { ], ), ), - _DetailRow(AppLocalizations.of(context)!.lastSeen, contact.timeSinceLastSeen), + _DetailRow( + AppLocalizations.of(context)!.lastSeen, + contact.timeSinceLastSeen, + ), const SizedBox(height: 16), // Room Login Status if (roomLoginState != null) ...[ @@ -627,12 +700,16 @@ class ContactTile extends StatelessWidget { const SizedBox(height: 8), _DetailRow( AppLocalizations.of(context)!.loginStatus, - roomLoginState.isLoggedIn ? AppLocalizations.of(context)!.loggedIn : AppLocalizations.of(context)!.notLoggedIn, + roomLoginState.isLoggedIn + ? AppLocalizations.of(context)!.loggedIn + : AppLocalizations.of(context)!.notLoggedIn, ), if (roomLoginState.isLoggedIn) ...[ _DetailRow( AppLocalizations.of(context)!.adminAccess, - roomLoginState.isAdmin ? AppLocalizations.of(context)!.yes : AppLocalizations.of(context)!.no, + roomLoginState.isAdmin + ? AppLocalizations.of(context)!.yes + : AppLocalizations.of(context)!.no, ), _DetailRow( AppLocalizations.of(context)!.permissions, @@ -646,7 +723,9 @@ class ContactTile extends StatelessWidget { ], _DetailRow( AppLocalizations.of(context)!.passwordSaved, - roomLoginState.hasPassword ? AppLocalizations.of(context)!.yes : AppLocalizations.of(context)!.no, + roomLoginState.hasPassword + ? AppLocalizations.of(context)!.yes + : AppLocalizations.of(context)!.no, ), const SizedBox(height: 16), ], @@ -679,7 +758,10 @@ class ContactTile extends StatelessWidget { icon: const Icon(Icons.map, size: 18), label: Text(AppLocalizations.of(context)!.viewOnMap), style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), ), ), ], @@ -695,25 +777,37 @@ class ContactTile extends StatelessWidget { _DetailRowWithCopy( context, 'DMS', - _convertToDMS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + _convertToDMS( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), ), // Degrees Decimal Minutes (DDM) _DetailRowWithCopy( context, 'DDM', - _convertToDDM(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + _convertToDDM( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), ), // MGRS (Military Grid Reference System) _DetailRowWithCopy( context, 'MGRS', - _convertToMGRS(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + _convertToMGRS( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), ), // Google Plus Code _DetailRowWithCopy( context, 'Plus Code', - _convertToPlusCode(contact.displayLocation!.latitude, contact.displayLocation!.longitude), + _convertToPlusCode( + contact.displayLocation!.latitude, + contact.displayLocation!.longitude, + ), ), const SizedBox(height: 16), ], @@ -730,14 +824,26 @@ class ContactTile extends StatelessWidget { ), TextButton.icon( onPressed: () { - final connectionProvider = context.read(); - connectionProvider.requestTelemetry(contact.publicKey, zeroHop: true); - ToastLogger.info(context, AppLocalizations.of(context)!.requestingTelemetry(contact.displayName)); + final connectionProvider = context + .read(); + connectionProvider.requestTelemetry( + contact.publicKey, + zeroHop: true, + ); + ToastLogger.info( + context, + AppLocalizations.of( + context, + )!.requestingTelemetry(contact.displayName), + ); }, icon: const Icon(Icons.refresh, size: 18), label: Text(AppLocalizations.of(context)!.refresh), style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), ), ), ], @@ -750,13 +856,25 @@ class ContactTile extends StatelessWidget { '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', ) else if (contact.telemetry!.batteryPercentage != null) - _DetailRow(AppLocalizations.of(context)!.battery, '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'), + _DetailRow( + AppLocalizations.of(context)!.battery, + '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%', + ), if (contact.telemetry!.temperature != null) - _DetailRow(AppLocalizations.of(context)!.temperature, '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), + _DetailRow( + AppLocalizations.of(context)!.temperature, + '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', + ), if (contact.telemetry!.humidity != null) - _DetailRow(AppLocalizations.of(context)!.humidity, '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), + _DetailRow( + AppLocalizations.of(context)!.humidity, + '${contact.telemetry!.humidity!.toStringAsFixed(1)}%', + ), if (contact.telemetry!.pressure != null) - _DetailRow(AppLocalizations.of(context)!.pressure, '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), + _DetailRow( + AppLocalizations.of(context)!.pressure, + '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', + ), if (contact.telemetry!.gpsLocation != null) _DetailRow( AppLocalizations.of(context)!.gpsTelemetry, @@ -778,7 +896,9 @@ class ContactTile extends StatelessWidget { _showDirectMessageDialog(context, contact); }, icon: const Icon(Icons.message), - label: Text(AppLocalizations.of(context)!.sendDirectMessage), + label: Text( + AppLocalizations.of(context)!.sendDirectMessage, + ), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), backgroundColor: _getTypeColor(contact.type, context), @@ -792,20 +912,28 @@ class ContactTile extends StatelessWidget { child: OutlinedButton.icon( onPressed: () { connectionProvider.resetPath(contact.publicKey); - ToastLogger.info(context, AppLocalizations.of(context)!.pathResetInfo(contact.displayName)); + ToastLogger.info( + context, + AppLocalizations.of( + context, + )!.pathResetInfo(contact.displayName), + ); }, icon: const Icon(Icons.route), label: Text(AppLocalizations.of(context)!.resetPath), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), - side: BorderSide(color: _getTypeColor(contact.type, context)), + side: BorderSide( + color: _getTypeColor(contact.type, context), + ), foregroundColor: _getTypeColor(contact.type, context), ), ), ), ], // Room Login button for room contacts (except Public Channel) - if (contact.type == ContactType.room && !contact.isPublicChannel) ...[ + if (contact.type == ContactType.room && + !contact.isPublicChannel) ...[ const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -815,7 +943,11 @@ class ContactTile extends StatelessWidget { _showRoomLoginDialog(context, contact); }, icon: const Icon(Icons.login), - label: Text(roomLoginState?.isLoggedIn == true ? AppLocalizations.of(context)!.reLoginToRoom : AppLocalizations.of(context)!.loginToRoom), + label: Text( + roomLoginState?.isLoggedIn == true + ? AppLocalizations.of(context)!.reLoginToRoom + : AppLocalizations.of(context)!.loginToRoom, + ), style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), backgroundColor: _getTypeColor(contact.type, context), @@ -830,9 +962,12 @@ class ContactTile extends StatelessWidget { SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () => _showDeleteConfirmation(context, contact), + onPressed: () => + _showDeleteConfirmation(context, contact), icon: const Icon(Icons.delete_outline), - label: Text(AppLocalizations.of(context)!.deleteContact), + label: Text( + AppLocalizations.of(context)!.deleteContact, + ), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), side: const BorderSide(color: Colors.red), @@ -863,9 +998,7 @@ class ContactTile extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w500), ), ), - Expanded( - child: Text(value), - ), + Expanded(child: Text(value)), ], ), ); @@ -884,16 +1017,16 @@ class ContactTile extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w500), ), ), - Expanded( - child: Text(value), - ), + Expanded(child: Text(value)), const SizedBox(width: 8), InkWell( onTap: () { Clipboard.setData(ClipboardData(text: value)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)), + content: Text( + AppLocalizations.of(context)!.copiedToClipboard(label), + ), duration: const Duration(seconds: 2), ), ); @@ -931,7 +1064,7 @@ class ContactTile extends StatelessWidget { int lonMin = lonMinDec.floor(); double lonSec = (lonMinDec - lonMin) * 60; - return '$latDeg°${latMin}\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°${lonMin}\'${lonSec.toStringAsFixed(2)}"$lonDir'; + return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir'; } /// Convert to Degrees Decimal Minutes (DDM) format @@ -966,7 +1099,7 @@ class ContactTile extends StatelessWidget { // Simplified - just show zone designation // Full MGRS would require UTM conversion library - return '${zone}$letter (approximate)'; + return '$zone$letter (approximate)'; } /// Convert to Google Plus Code format @@ -1061,7 +1194,11 @@ class ContactTile extends StatelessWidget { String _formatTimestamp(DateTime timestamp) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); - final timestampDate = DateTime(timestamp.year, timestamp.month, timestamp.day); + final timestampDate = DateTime( + timestamp.year, + timestamp.month, + timestamp.day, + ); if (timestampDate == today) { // Today - show time only diff --git a/lib/widgets/contacts/room_login_sheet.dart b/lib/widgets/contacts/room_login_sheet.dart index 0cb5882..d165ebe 100644 --- a/lib/widgets/contacts/room_login_sheet.dart +++ b/lib/widgets/contacts/room_login_sheet.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -86,7 +85,9 @@ class _RoomLoginSheetState extends State { }); // 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues - debugPrint('🕐 [RoomLogin] Checking for clock drift between app and radio...'); + debugPrint( + '🕐 [RoomLogin] Checking for clock drift between app and radio...', + ); try { await connectionProvider.getDeviceTime(); // Give time for response to be logged @@ -97,18 +98,26 @@ class _RoomLoginSheetState extends State { } // 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device - debugPrint('🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...'); - debugPrint(' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}'); + debugPrint( + '🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...', + ); + debugPrint( + ' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}', + ); // Check if the room exists in our local contacts bool roomExists = contactsProvider.rooms.any( (room) => room.publicKeyHex == widget.contact.publicKeyHex, ); - debugPrint(' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}'); + debugPrint( + ' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}', + ); if (!roomExists) { - debugPrint('âš ī¸ [RoomLogin] Room not in local contacts - syncing with device...'); + debugPrint( + 'âš ī¸ [RoomLogin] Room not in local contacts - syncing with device...', + ); try { // Sync contacts from device @@ -122,24 +131,32 @@ class _RoomLoginSheetState extends State { (room) => room.publicKeyHex == widget.contact.publicKeyHex, ); - debugPrint(' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}'); + debugPrint( + ' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}', + ); if (!roomExists) { // Room still doesn't exist on the device - try to add it manually debugPrint('❌ [RoomLogin] Room still not found after sync'); - debugPrint('🔧 [RoomLogin] Attempting to add room contact to companion radio...'); + debugPrint( + '🔧 [RoomLogin] Attempting to add room contact to companion radio...', + ); try { // Manually add the room contact to the radio's flash storage await connectionProvider.addOrUpdateContact(widget.contact); - debugPrint('✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT'); + debugPrint( + '✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT', + ); debugPrint(' Waiting 500ms for radio to save to flash...'); // Give the radio time to save the contact to flash await Future.delayed(const Duration(milliseconds: 500)); - debugPrint('✅ [RoomLogin] Room contact should now be available - proceeding with login'); + debugPrint( + '✅ [RoomLogin] Room contact should now be available - proceeding with login', + ); } catch (e) { debugPrint('❌ [RoomLogin] Failed to add room contact: $e'); @@ -151,7 +168,9 @@ class _RoomLoginSheetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToAddRoom(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToAddRoom(e.toString()), + ), backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 7), ), @@ -159,16 +178,22 @@ class _RoomLoginSheetState extends State { // Log available rooms for debugging final availableRooms = contactsProvider.rooms; - debugPrint('📋 [RoomLogin] Available rooms on device (${availableRooms.length}):'); + debugPrint( + '📋 [RoomLogin] Available rooms on device (${availableRooms.length}):', + ); for (final room in availableRooms) { - debugPrint(' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})'); + debugPrint( + ' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})', + ); } return; } } - debugPrint('✅ [RoomLogin] Room contact found after sync - proceeding with login'); + debugPrint( + '✅ [RoomLogin] Room contact found after sync - proceeding with login', + ); } catch (e) { debugPrint('❌ [RoomLogin] Contact sync failed: $e'); @@ -180,14 +205,18 @@ class _RoomLoginSheetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToSyncContacts(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToSyncContacts(e.toString()), + ), backgroundColor: Theme.of(context).colorScheme.error, ), ); return; } } else { - debugPrint('✅ [RoomLogin] Room contact found in local contacts - proceeding with login'); + debugPrint( + '✅ [RoomLogin] Room contact found in local contacts - proceeding with login', + ); } // Save password before sending @@ -200,14 +229,21 @@ class _RoomLoginSheetState extends State { originalOnSuccess = connectionProvider.onLoginSuccess; originalOnFail = connectionProvider.onLoginFail; - connectionProvider.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { + connectionProvider + .onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async { // Restore original callback connectionProvider.onLoginSuccess = originalOnSuccess; connectionProvider.onLoginFail = originalOnFail; - debugPrint('✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin'); - debugPrint('📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING'); - debugPrint(' Messages will be fetched when onMessageWaiting callback is triggered'); + debugPrint( + '✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin', + ); + debugPrint( + '📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING', + ); + debugPrint( + ' Messages will be fetched when onMessageWaiting callback is triggered', + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -252,7 +288,9 @@ class _RoomLoginSheetState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.loggingIn(widget.contact.displayName)), + content: Text( + AppLocalizations.of(context)!.loggingIn(widget.contact.displayName), + ), backgroundColor: Theme.of(context).colorScheme.primary, duration: const Duration(seconds: 2), ), @@ -265,7 +303,9 @@ class _RoomLoginSheetState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(AppLocalizations.of(context)!.failedToSendLogin(e.toString())), + content: Text( + AppLocalizations.of(context)!.failedToSendLogin(e.toString()), + ), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -350,7 +390,11 @@ class _RoomLoginSheetState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.info_outline, color: colorScheme.onPrimaryContainer, size: 20), + Icon( + Icons.info_outline, + color: colorScheme.onPrimaryContainer, + size: 20, + ), const SizedBox(width: 12), Expanded( child: Text( @@ -378,72 +422,83 @@ class _RoomLoginSheetState extends State { ), child: Column( mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: _passwordController, - focusNode: _focusNode, - maxLength: 15, // Max password length from protocol - obscureText: _obscurePassword, - autofocus: true, - maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.password, - labelStyle: TextStyle(color: colorScheme.onSurfaceVariant), - hintText: AppLocalizations.of(context)!.enterRoomPassword, - hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - contentPadding: const EdgeInsets.all(16), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword ? Icons.visibility : Icons.visibility_off, + children: [ + TextField( + controller: _passwordController, + focusNode: _focusNode, + maxLength: 15, // Max password length from protocol + obscureText: _obscurePassword, + autofocus: true, + maxLengthEnforcement: MaxLengthEnforcement.enforced, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.password, + labelStyle: TextStyle( color: colorScheme.onSurfaceVariant, ), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, + hintText: AppLocalizations.of(context)!.enterRoomPassword, + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + contentPadding: const EdgeInsets.all(16), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + color: colorScheme.onSurfaceVariant, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _loginToRoom(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _isLoggingIn ? null : _loginToRoom, + icon: _isLoggingIn + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.login), + label: Text( + _isLoggingIn + ? AppLocalizations.of(context)!.loggingInDots + : AppLocalizations.of(context)!.login, + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), ), ), - textInputAction: TextInputAction.done, - onSubmitted: (_) => _loginToRoom(), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isLoggingIn ? null : _loginToRoom, - icon: _isLoggingIn - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.login), - label: Text(_isLoggingIn ? AppLocalizations.of(context)!.loggingInDots : AppLocalizations.of(context)!.login), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - ), - ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), ); } } diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 5163b50..528d7bb 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/drawing_provider.dart'; @@ -352,10 +351,18 @@ class DrawingToolbar extends StatelessWidget { // Read providers BEFORE any async operations or dialogs // This ensures we have the correct BuildContext - final connectionProvider = Provider.of(context, listen: false); - final contactsProvider = Provider.of(context, listen: false); + final connectionProvider = Provider.of( + context, + listen: false, + ); + final contactsProvider = Provider.of( + context, + listen: false, + ); - debugPrint(' Connection status: ${connectionProvider.deviceInfo.isConnected}'); + debugPrint( + ' Connection status: ${connectionProvider.deviceInfo.isConnected}', + ); if (!connectionProvider.deviceInfo.isConnected) { ScaffoldMessenger.of(context).showSnackBar( @@ -444,9 +451,13 @@ class DrawingToolbar extends StatelessWidget { (room) => ListTile( leading: const Icon(Icons.meeting_room, color: Colors.green), title: Text(room.advName), - subtitle: Text(AppLocalizations.of(context)!.storedPermanently), + subtitle: Text( + AppLocalizations.of(context)!.storedPermanently, + ), onTap: () async { - debugPrint('📤 [DrawingToolbar] Room ${room.advName} tapped'); + debugPrint( + '📤 [DrawingToolbar] Room ${room.advName} tapped', + ); // Share BEFORE popping the navigator await _shareDrawingsToRoom( sheetContext, @@ -485,7 +496,10 @@ class DrawingToolbar extends StatelessWidget { return; } - final drawingProvider = Provider.of(context, listen: false); + final drawingProvider = Provider.of( + context, + listen: false, + ); int successCount = 0; for (final drawing in drawings) { @@ -493,7 +507,9 @@ class DrawingToolbar extends StatelessWidget { debugPrint(' Creating message for drawing ${drawing.id}...'); // Sender name is no longer included in JSON - will be extracted from packet metadata final message = drawingProvider.createDrawingBroadcastMessage(drawing); - debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); + debugPrint( + ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', + ); debugPrint(' Sending to channel 0...'); await connectionProvider.sendChannelMessage( channelIdx: 0, @@ -518,10 +534,14 @@ class DrawingToolbar extends StatelessWidget { } // Add informational message to chat - final messagesProvider = Provider.of(context, listen: false); + final messagesProvider = Provider.of( + context, + listen: false, + ); final l10n = AppLocalizations.of(context)!; messagesProvider.logSystemMessage( - text: '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}', + text: + '📤 ${l10n.drawingsSentToPublicChannel(drawings.length, drawings.length > 1 ? 's' : '')}', level: 'info', ); @@ -556,7 +576,10 @@ class DrawingToolbar extends StatelessWidget { return; } - final drawingProvider = Provider.of(context, listen: false); + final drawingProvider = Provider.of( + context, + listen: false, + ); int successCount = 0; for (final drawing in drawings) { @@ -564,7 +587,9 @@ class DrawingToolbar extends StatelessWidget { debugPrint(' Creating message for drawing ${drawing.id}...'); // Sender name is no longer included in JSON - will be extracted from packet metadata final message = drawingProvider.createDrawingBroadcastMessage(drawing); - debugPrint(' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...'); + debugPrint( + ' Message created (${message.length} chars): ${message.substring(0, message.length > 100 ? 100 : message.length)}...', + ); debugPrint(' Sending to room ${room.advName}...'); await connectionProvider.sendTextMessage( contactPublicKey: room.publicKey, @@ -591,9 +616,13 @@ class DrawingToolbar extends StatelessWidget { } // Add informational message to chat - final messagesProvider = Provider.of(context, listen: false); + final messagesProvider = Provider.of( + context, + listen: false, + ); messagesProvider.logSystemMessage( - text: '📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to ${room.advName}', + text: + '📤 Sent ${drawings.length} map drawing${drawings.length > 1 ? 's' : ''} to ${room.advName}', level: 'info', ); diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index e188e78..0e5e51c 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -37,7 +37,8 @@ class _RecipientSelectorSheetState extends State { if (_searchQuery.isEmpty) return contacts; final query = _searchQuery.toLowerCase(); return contacts.where((contact) { - final name = contact.displayName?.toLowerCase() ?? contact.advName.toLowerCase(); + final name = + contact.displayName.toLowerCase() ?? contact.advName.toLowerCase(); return name.contains(query); }).toList(); } @@ -81,9 +82,9 @@ class _RecipientSelectorSheetState extends State { children: [ Text( l10n.selectRecipient, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const Spacer(), IconButton( @@ -254,17 +255,19 @@ class _RecipientSelectorSheetState extends State { const SizedBox(height: 16), Text( l10n.noContactsOrRoomsAvailable, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).disabledColor, - ), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: Theme.of(context).disabledColor, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( l10n.messagesWillBeSentToPublicChannel, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).disabledColor, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of(context).disabledColor, + ), textAlign: TextAlign.center, ), ], @@ -297,7 +300,7 @@ class _RecipientSelectorSheetState extends State { decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceVariant, + : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), child: Icon( @@ -325,11 +328,10 @@ class _RecipientSelectorSheetState extends State { ), subtitle: Text( subtitle, - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - ).copyWith( - color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + style: const TextStyle(fontSize: 12, fontFamily: 'monospace').copyWith( + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ), trailing: isSelected diff --git a/lib/widgets/messages/sar_update_sheet.dart b/lib/widgets/messages/sar_update_sheet.dart index de8181f..cb19e4d 100644 --- a/lib/widgets/messages/sar_update_sheet.dart +++ b/lib/widgets/messages/sar_update_sheet.dart @@ -12,7 +12,14 @@ import '../../l10n/app_localizations.dart'; /// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers /// This widget is public so it can be used from both messages_tab.dart and map_tab.dart class SarUpdateSheet extends StatefulWidget { - final Future Function(String emoji, String name, Position, Uint8List?, bool) onSend; + final Future Function( + String emoji, + String name, + Position, + Uint8List?, + bool, + ) + onSend; final Position? prePopulatedPosition; final bool allowLocationUpdate; @@ -34,7 +41,8 @@ class _SarUpdateSheetState extends State { Position? _currentPosition; bool _loadingLocation = false; String? _locationError; - Contact? _selectedContact; // Can be room or channel (public channel is in contacts) + Contact? + _selectedContact; // Can be room or channel (public channel is in contacts) final TextEditingController _notesController = TextEditingController(); @override @@ -176,564 +184,642 @@ class _SarUpdateSheetState extends State { children: [ // Header Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), - onPressed: () => Navigator.pop(context), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), ), - Expanded( - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.sendSarMarker, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - Text( - AppLocalizations.of(context)!.quickLocationMarker, - style: TextStyle( - color: colorScheme.onSurfaceVariant, - fontSize: 14, - ), - ), - ], - ), - ), - const SizedBox(width: 48), // Spacer to keep title centered - ], - ), - ), - // Content - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - // Add bottom padding for button area (button + padding + safe area) - // Button height ~48px + container padding 32px + safe area - bottom: 80 + bottomSafeArea, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - // Marker type selection - Text( - AppLocalizations.of(context)!.markerType, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.bold, - ), + IconButton( + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), + onPressed: () => Navigator.pop(context), ), - const SizedBox(height: 12), - ..._templates.map((template) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: TemplateChip( - template: template, - isSelected: _selectedTemplate?.id == template.id, - onTap: () => setState(() => _selectedTemplate = template), - ), - ); - }).toList(), - const SizedBox(height: 16), - - // Destination selection (compact dropdown with rooms and channel) - Text( - AppLocalizations.of(context)!.sendTo, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Consumer( - builder: (context, contactsProvider, child) { - // Get all valid destinations (rooms + channels) - final destinations = contactsProvider.roomsAndChannels; - - if (destinations.isEmpty) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - AppLocalizations.of(context)!.noDestinationsAvailable, - style: const TextStyle( - color: Colors.white70, - fontSize: 11, - ), - ), - ), - ], - ), - ); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.3), - width: 1, + Expanded( + child: Column( + children: [ + Text( + AppLocalizations.of(context)!.sendSarMarker, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 18, + fontWeight: FontWeight.bold, ), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedContact, - hint: Row( - children: [ - Icon(Icons.arrow_drop_down_circle, size: 18, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 12), - Text( - AppLocalizations.of(context)!.selectDestination, - style: TextStyle(color: colorScheme.onSurfaceVariant), - ), - ], - ), - dropdownColor: colorScheme.surfaceContainerHighest, - isExpanded: true, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 14, - ), - icon: Icon(Icons.arrow_drop_down, color: colorScheme.onSurface), - items: destinations.map((contact) { - return DropdownMenuItem( - value: contact, - child: Row( - children: [ - Icon( - contact.isChannel ? Icons.public : Icons.storage, - size: 18, - color: colorScheme.onSurface, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - contact.getLocalizedDisplayName(context), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - }).toList(), - onChanged: (value) { - setState(() => _selectedContact = value); - }, - ), - ), - ); - }, - ), - const SizedBox(height: 12), - - // Compact info banner - if (_selectedContact != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _selectedContact!.isChannel - ? Colors.orange.withValues(alpha: 0.1) - : Colors.blue.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _selectedContact!.isChannel - ? Colors.orange.withValues(alpha: 0.3) - : Colors.blue.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - _selectedContact!.isChannel - ? Icons.warning_amber - : Icons.check_circle_outline, - color: _selectedContact!.isChannel - ? Colors.orange - : Colors.blue, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _selectedContact!.isChannel - ? AppLocalizations.of(context)!.ephemeralBroadcastInfo - : AppLocalizations.of(context)!.persistentRoomInfo, - style: const TextStyle( - color: Colors.white70, - fontSize: 11, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Location display - Row( - children: [ - Text( - AppLocalizations.of(context)!.location, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - if (!widget.allowLocationUpdate) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: Colors.blue.withValues(alpha: 0.5), - width: 1, - ), - ), - child: Text( - AppLocalizations.of(context)!.fromMap, - style: const TextStyle( - color: Colors.blue, - fontSize: 11, - fontWeight: FontWeight.bold, - ), + Text( + AppLocalizations.of(context)!.quickLocationMarker, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 14, ), ), ], - ], - ), - const SizedBox(height: 12), - if (_loadingLocation) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(colorScheme.primary), - ), - ), - const SizedBox(width: 16), - Text( - AppLocalizations.of(context)!.gettingLocation, - style: TextStyle(color: colorScheme.onSurface), - ), - ], - ), - ) - else if (_locationError != null) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 24), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of(context)!.locationError, - style: const TextStyle( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - const SizedBox(height: 4), - Text( - _locationError!, - style: TextStyle( - color: Colors.red.shade700, - fontSize: 12, - ), - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.refresh, color: Colors.red), - onPressed: _getCurrentLocation, - tooltip: AppLocalizations.of(context)!.retry, - ), - ], - ), - ) - else if (_currentPosition != null) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.location_on, - size: 20, - color: Colors.green, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}', - style: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, - ), - ), - ), - // Only show refresh button if location updates are allowed - if (widget.allowLocationUpdate) - IconButton( - icon: Icon(Icons.refresh, size: 20, color: colorScheme.onSurface), - onPressed: _getCurrentLocation, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: AppLocalizations.of(context)!.refreshLocation, - ), - ], - ), - if (_currentPosition!.accuracy != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.my_location, - size: 14, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - AppLocalizations.of(context)!.accuracyMeters( - _currentPosition!.accuracy!.round(), - ), - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ], - ), - ), - const SizedBox(height: 24), - - // Optional notes - Text( - AppLocalizations.of(context)!.notesOptional, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 3, - maxLength: 100, - style: TextStyle(fontSize: 14, color: colorScheme.onSurface), - decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.addAdditionalInformation, - hintStyle: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), - filled: true, - fillColor: colorScheme.surfaceContainerHighest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.all(16), - ), - ), - const SizedBox(height: 8), + const SizedBox(width: 48), // Spacer to keep title centered ], ), ), - ), - // Bottom action button - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colorScheme.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 4, - offset: const Offset(0, -2), + // Content + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + // Add bottom padding for button area (button + padding + safe area) + // Button height ~48px + container padding 32px + safe area + bottom: 80 + bottomSafeArea, ), - ], - ), - child: SafeArea( - top: false, - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _currentPosition == null || _selectedContact == null || _selectedTemplate == null - ? null - : () async { - final validator = ValidationService(); - final notes = _notesController.text.trim(); + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Marker type selection + Text( + AppLocalizations.of(context)!.markerType, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ..._templates.map((template) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TemplateChip( + template: template, + isSelected: _selectedTemplate?.id == template.id, + onTap: () => + setState(() => _selectedTemplate = template), + ), + ); + }), + const SizedBox(height: 16), - // Validate notes length if provided - if (notes.isNotEmpty) { - final notesResult = validator.validateName( - notes, - maxLength: 100, + // Destination selection (compact dropdown with rooms and channel) + Text( + AppLocalizations.of(context)!.sendTo, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Consumer( + builder: (context, contactsProvider, child) { + // Get all valid destinations (rooms + channels) + final destinations = contactsProvider.roomsAndChannels; + + if (destinations.isEmpty) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.noDestinationsAvailable, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + ), + ], + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedContact, + hint: Row( + children: [ + Icon( + Icons.arrow_drop_down_circle, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of( + context, + )!.selectDestination, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + dropdownColor: + colorScheme.surfaceContainerHighest, + isExpanded: true, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 14, + ), + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurface, + ), + items: destinations.map((contact) { + return DropdownMenuItem( + value: contact, + child: Row( + children: [ + Icon( + contact.isChannel + ? Icons.public + : Icons.storage, + size: 18, + color: colorScheme.onSurface, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + contact.getLocalizedDisplayName( + context, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + onChanged: (value) { + setState(() => _selectedContact = value); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 12), + + // Compact info banner + if (_selectedContact != null) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.1) + : Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _selectedContact!.isChannel + ? Colors.orange.withValues(alpha: 0.3) + : Colors.blue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + _selectedContact!.isChannel + ? Icons.warning_amber + : Icons.check_circle_outline, + color: _selectedContact!.isChannel + ? Colors.orange + : Colors.blue, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _selectedContact!.isChannel + ? AppLocalizations.of( + context, + )!.ephemeralBroadcastInfo + : AppLocalizations.of( + context, + )!.persistentRoomInfo, + style: const TextStyle( + color: Colors.white70, + fontSize: 11, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Location display + Row( + children: [ + Text( + AppLocalizations.of(context)!.location, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + if (!widget.allowLocationUpdate) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.5), + width: 1, + ), + ), + child: Text( + AppLocalizations.of(context)!.fromMap, + style: const TextStyle( + color: Colors.blue, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 12), + if (_loadingLocation) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), + ), + ), + const SizedBox(width: 16), + Text( + AppLocalizations.of(context)!.gettingLocation, + style: TextStyle(color: colorScheme.onSurface), + ), + ], + ), + ) + else if (_locationError != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.error_outline, + color: Colors.red, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.locationError, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(height: 4), + Text( + _locationError!, + style: TextStyle( + color: Colors.red.shade700, + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon( + Icons.refresh, + color: Colors.red, + ), + onPressed: _getCurrentLocation, + tooltip: AppLocalizations.of(context)!.retry, + ), + ], + ), + ) + else if (_currentPosition != null) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.location_on, + size: 20, + color: Colors.green, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + ), + ), + // Only show refresh button if location updates are allowed + if (widget.allowLocationUpdate) + IconButton( + icon: Icon( + Icons.refresh, + size: 20, + color: colorScheme.onSurface, + ), + onPressed: _getCurrentLocation, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: AppLocalizations.of( + context, + )!.refreshLocation, + ), + ], + ), + ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.my_location, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + AppLocalizations.of( + context, + )!.accuracyMeters( + _currentPosition!.accuracy!.round(), + ), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Optional notes + Text( + AppLocalizations.of(context)!.notesOptional, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + maxLength: 100, + style: TextStyle( + fontSize: 14, + color: colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: AppLocalizations.of( + context, + )!.addAdditionalInformation, + hintStyle: TextStyle( + fontSize: 14, + color: colorScheme.onSurfaceVariant, + ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + // Bottom action button + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + _currentPosition == null || + _selectedContact == null || + _selectedTemplate == null + ? null + : () async { + final validator = ValidationService(); + final notes = _notesController.text.trim(); + + // Validate notes length if provided + if (notes.isNotEmpty) { + final notesResult = validator.validateName( + notes, + maxLength: 100, + ); + if (!notesResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(notesResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + + // Validate coordinates + final coordResult = validator.validateCoordinates( + _currentPosition!.latitude, + _currentPosition!.longitude, ); - if (!notesResult.isValid) { + if (!coordResult.isValid) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(notesResult.errorMessage!), + content: Text(coordResult.errorMessage!), backgroundColor: Colors.red, ), ); } return; } - } - // Validate coordinates - final coordResult = validator.validateCoordinates( - _currentPosition!.latitude, - _currentPosition!.longitude, - ); - if (!coordResult.isValid) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(coordResult.errorMessage!), - backgroundColor: Colors.red, + // Validate location accuracy (warn if >50m) + if (_currentPosition!.accuracy > 50.0) { + final shouldContinue = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + AppLocalizations.of( + context, + )!.lowLocationAccuracy, + ), + content: Text( + AppLocalizations.of( + context, + )!.lowAccuracyWarning( + _currentPosition!.accuracy.round(), + ), + ), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context, false), + child: Text( + AppLocalizations.of(context)!.cancel, + ), + ), + TextButton( + onPressed: () => + Navigator.pop(context, true), + child: Text( + AppLocalizations.of(context)!.continue_, + ), + ), + ], ), ); + if (shouldContinue != true) return; } - return; - } - // Validate location accuracy (warn if >50m) - if (_currentPosition!.accuracy != null && - _currentPosition!.accuracy! > 50.0) { - final shouldContinue = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(AppLocalizations.of(context)!.lowLocationAccuracy), - content: Text( - AppLocalizations.of(context)!.lowAccuracyWarning( - _currentPosition!.accuracy!.round(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(AppLocalizations.of(context)!.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text(AppLocalizations.of(context)!.continue_), - ), - ], - ), + // Combine template name with optional notes + String displayText; + if (notes.isNotEmpty) { + // Include both template name and custom notes + displayText = + '${_selectedTemplate!.name} - $notes'; + } else { + // Just the template name + displayText = _selectedTemplate!.name; + } + + // Send SAR marker with emoji and display text + await widget.onSend( + _selectedTemplate!.emoji, + displayText, + _currentPosition!, + _selectedContact!.isChannel + ? null + : _selectedContact!.publicKey, + _selectedContact!.isChannel, ); - if (shouldContinue != true) return; - } - - // Combine template name with optional notes - String displayText; - if (notes.isNotEmpty) { - // Include both template name and custom notes - displayText = '${_selectedTemplate!.name} - $notes'; - } else { - // Just the template name - displayText = _selectedTemplate!.name; - } - - // Send SAR marker with emoji and display text - await widget.onSend( - _selectedTemplate!.emoji, - displayText, - _currentPosition!, - _selectedContact!.isChannel - ? null - : _selectedContact!.publicKey, - _selectedContact!.isChannel, - ); - if (context.mounted) { - Navigator.pop(context); - } - }, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - disabledBackgroundColor: Colors.grey, - disabledForegroundColor: Colors.white70, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + if (context.mounted) { + Navigator.pop(context); + } + }, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + disabledBackgroundColor: Colors.grey, + disabledForegroundColor: Colors.white70, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.send, size: 20), + label: Text( + AppLocalizations.of(context)!.sendSarMarker, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - icon: const Icon(Icons.send, size: 20), - label: Text( - AppLocalizations.of(context)!.sendSarMarker, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), ), ), - ), - ], - ), + ], + ), ), ); } @@ -776,10 +862,7 @@ class TemplateChip extends StatelessWidget { ), child: Row( children: [ - Text( - template.emoji, - style: const TextStyle(fontSize: 32), - ), + Text(template.emoji, style: const TextStyle(fontSize: 32)), const SizedBox(width: 16), Expanded( child: Column( @@ -808,12 +891,7 @@ class TemplateChip extends StatelessWidget { ], ), ), - if (isSelected) - Icon( - Icons.check_circle, - color: color, - size: 24, - ), + if (isSelected) Icon(Icons.check_circle, color: color, size: 24), ], ), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index c05da1e..dd7b317 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,7 +5,6 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/main.dart';