From 91d9326804662c1610750b1ee51e682c3501ffbb Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 15 Oct 2025 19:20:46 +0200 Subject: [PATCH] Refactor contacts management and improve path handling - Updated ContactsProvider to initialize with device public key for filtering out self contacts. - Added methods to check if a contact has a learned routing path and to get path quality indicators. - Enhanced AppProvider to initialize ContactsProvider with device public key. - Modified ConnectionProvider to log path status when sending messages. - Created ContactStorageService for persisting contacts to local storage. - Removed import/export functionality from MapManagementScreen. - Updated UI components in DirectMessageSheet and SarUpdateSheet to use theme colors. - Removed file_picker dependency from pubspec.yaml and generated plugin registrant. --- ios/Runner.xcodeproj/project.pbxproj | 12 +- ios/Runner/Info.plist | 2 +- ios/fastlane/report.xml | 19 +- lib/main.dart | 8 +- lib/models/contact.dart | 35 +++ lib/providers/app_provider.dart | 20 +- lib/providers/connection_provider.dart | 14 ++ lib/providers/contacts_provider.dart | 91 +++++++- lib/screens/map_management_screen.dart | 99 +-------- lib/screens/messages_tab.dart | 34 +++ lib/services/contact_storage_service.dart | 210 ++++++++++++++++++ lib/services/tile_cache_service.dart | 38 ---- .../contacts/direct_message_sheet.dart | 47 ++-- lib/widgets/messages/sar_update_sheet.dart | 108 +++++---- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 16 -- pubspec.yaml | 1 - 17 files changed, 501 insertions(+), 255 deletions(-) create mode 100644 lib/services/contact_storage_service.dart diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7076992..b4e7147 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -529,7 +529,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -545,7 +545,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests; @@ -676,7 +676,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -699,7 +699,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7024c61..1207dd5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 1 + 2 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index f9dd048..84a4a7a 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,24 +5,7 @@ - - - - - - - - - - - - - - - - - - + diff --git a/lib/main.dart b/lib/main.dart index 2b1a3ab..1d4a01e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -68,7 +68,13 @@ class _MeshCoreSarAppState extends State { providers: [ // Core providers ChangeNotifierProvider(create: (_) => ConnectionProvider()), - ChangeNotifierProvider(create: (_) => ContactsProvider()), + ChangeNotifierProvider( + create: (_) { + // Don't initialize here - it will be initialized in AppProvider.initialize() + // after connection is established and device info is available + return ContactsProvider(); + }, + ), ChangeNotifierProvider( create: (_) { final provider = MessagesProvider(); diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 5133681..d9f4142 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -203,6 +203,41 @@ class Contact { return advName.substring(emoji.length).trim(); } + /// Check if contact has a learned routing path + /// When true, messages will use direct routing. When false, messages will use flood mode. + bool get hasPath => outPathLen > 0 && outPathLen <= 64; + + /// Get path description for UI display + String get pathDescription { + if (!hasPath) { + return 'No path (flood mode)'; + } + + // outPathLen includes the number of hops in the path + final hops = outPathLen; + if (hops == 1) { + return 'Direct (0 hops)'; + } else if (hops <= 3) { + return 'Good path (${hops - 1} hop${hops - 1 > 1 ? 's' : ''})'; + } else if (hops <= 5) { + return 'Medium path (${hops - 1} hops)'; + } else { + return 'Long path (${hops - 1} hops)'; + } + } + + /// Get path quality indicator (0-5 scale, higher is better) + /// -1 means no path (will use flood mode) + int get pathQuality { + if (!hasPath) return -1; + if (outPathLen == 1) return 5; // Direct connection (0 hops) + if (outPathLen <= 2) return 4; // 1 hop + if (outPathLen <= 3) return 3; // 2 hops + if (outPathLen <= 4) return 2; // 3 hops + if (outPathLen <= 5) return 1; // 4 hops + return 0; // 5+ hops + } + Contact copyWith({ Uint8List? publicKey, ContactType? type, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index a4dc371..f26252c 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -42,12 +42,20 @@ class AppProvider with ChangeNotifier { void _setupCallbacks() { // When a contact is received from BLE connectionProvider.onContactReceived = (contact) { - contactsProvider.addOrUpdateContact(contact); + // Pass device public key to filter out our own contact + contactsProvider.addOrUpdateContact( + contact, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); }; // When all contacts are received connectionProvider.onContactsComplete = (contacts) { - contactsProvider.addContacts(contacts); + // Pass device public key to filter out our own contact + contactsProvider.addContacts( + contacts, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); debugPrint('Received ${contacts.length} contacts'); }; @@ -118,6 +126,14 @@ class AppProvider with ChangeNotifier { if (!connectionProvider.deviceInfo.isConnected) return; try { + // Initialize contacts provider with device public key to exclude self + // This must happen before getContacts to ensure proper filtering + if (!contactsProvider.isInitialized) { + await contactsProvider.initialize( + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + } + // Sync device time await connectionProvider.syncDeviceTime(); diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index e0688ad..ebebdc4 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -463,10 +463,12 @@ class ConnectionProvider with ChangeNotifier { /// only that it was queued on the companion radio. /// /// [messageId] - optional message ID to track delivery status + /// [contact] - optional contact object for path status logging Future sendTextMessage({ required Uint8List contactPublicKey, required String text, String? messageId, + Contact? contact, }) async { if (!_bleService.isConnected) { _error = 'Not connected to device'; @@ -475,6 +477,18 @@ class ConnectionProvider with ChangeNotifier { } try { + // Log path status if contact info is available + if (contact != null) { + print('📤 [ConnectionProvider] Sending message to ${contact.advName}'); + print(' Type: ${contact.type.displayName}'); + print(' Path status: ${contact.pathDescription}'); + if (contact.hasPath) { + print(' ✅ Using learned path (${contact.outPathLen} bytes)'); + } else { + print(' âš ī¸ No path available - will use flood mode'); + } + } + // IMPORTANT: Track pending message BEFORE sending to avoid race condition // The SENT response can arrive so quickly that if we track after sending, // the callback will fire before we add the message ID to the queue. diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 057d2dd..c2408d1 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1,19 +1,52 @@ -import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../models/contact.dart'; -import '../models/contact_telemetry.dart'; import '../services/cayenne_lpp_parser.dart'; +import '../services/contact_storage_service.dart'; /// Contacts Provider - manages contact list and telemetry class ContactsProvider with ChangeNotifier { final Map _contacts = {}; + final ContactStorageService _storageService = ContactStorageService(); + bool _isInitialized = false; // Add default public channel on initialization ContactsProvider() { _ensurePublicChannelExists(); } + bool get isInitialized => _isInitialized; + + /// Initialize and load persisted contacts + /// [devicePublicKey] - device's own public key to exclude from loaded contacts + Future initialize({Uint8List? devicePublicKey}) async { + if (_isInitialized) return; + + try { + print('đŸ“Ļ [ContactsProvider] Loading persisted contacts...'); + final storedContacts = await _storageService.loadContacts( + excludePublicKey: devicePublicKey, + ); + + // Add stored contacts + for (final contact in storedContacts) { + _contacts[contact.publicKeyHex] = contact; + } + + _isInitialized = true; + print('✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts'); + + // Ensure public channel exists after loading + _ensurePublicChannelExists(); + + notifyListeners(); + } catch (e) { + print('❌ [ContactsProvider] Error initializing: $e'); + _isInitialized = true; // Mark as initialized even on error + _ensurePublicChannelExists(); + } + } + /// Ensure public channel always exists in the list void _ensurePublicChannelExists() { const publicChannelKey = 'public_channel_0'; @@ -34,6 +67,19 @@ class ContactsProvider with ChangeNotifier { } } + /// Persist contacts to storage (async, non-blocking) + Future _persistContacts() async { + try { + // Don't persist the public channel pseudo-contact + final contactsToSave = _contacts.values + .where((c) => c.publicKeyHex != 'public_channel_0') + .toList(); + await _storageService.saveContacts(contactsToSave); + } catch (e) { + print('❌ [ContactsProvider] Error persisting contacts: $e'); + } + } + List get contacts => _contacts.values.toList(); List get chatContacts => @@ -71,16 +117,45 @@ class ContactsProvider with ChangeNotifier { } /// Add or update a contact - void addOrUpdateContact(Contact contact) { + /// 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)) { + print('â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}'); + return; + } + _contacts[contact.publicKeyHex] = contact; + _persistContacts(); notifyListeners(); } + /// Compare two public keys for equality + bool _publicKeysMatch(Uint8List key1, Uint8List key2) { + if (key1.length != key2.length) return false; + for (int i = 0; i < key1.length; i++) { + if (key1[i] != key2[i]) return false; + } + return true; + } + /// Add multiple contacts - void addContacts(List contacts) { + /// Excludes contacts that match the device's own public key + void addContacts(List contacts, {Uint8List? devicePublicKey}) { + 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)) { + print('â„šī¸ [ContactsProvider] Ignoring contact with device\'s own public key: ${contact.advName}'); + excluded++; + continue; + } _contacts[contact.publicKeyHex] = contact; } + if (excluded > 0) { + print('â„šī¸ [ContactsProvider] Excluded $excluded contact(s) matching device public key'); + } + _persistContacts(); notifyListeners(); } @@ -97,6 +172,7 @@ class ContactsProvider with ChangeNotifier { // Update contact with new telemetry final updatedContact = contact.copyWith(telemetry: telemetry); _contacts[contact.publicKeyHex] = updatedContact; + _persistContacts(); notifyListeners(); } catch (e) { debugPrint('Failed to parse telemetry: $e'); @@ -151,15 +227,22 @@ class ContactsProvider with ChangeNotifier { /// Clear all contacts void clearContacts() { _contacts.clear(); + _persistContacts(); notifyListeners(); } /// Remove a contact void removeContact(String publicKeyHex) { _contacts.remove(publicKeyHex); + _persistContacts(); notifyListeners(); } + /// Get storage statistics + Future> getStorageStats() async { + return await _storageService.getStorageStats(); + } + /// Get contact count by type Map get contactCounts { return { diff --git a/lib/screens/map_management_screen.dart b/lib/screens/map_management_screen.dart index ea4b5e4..41276ea 100644 --- a/lib/screens/map_management_screen.dart +++ b/lib/screens/map_management_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:share_plus/share_plus.dart'; import '../services/tile_cache_service.dart'; import '../services/validation_service.dart'; import '../models/map_layer.dart'; @@ -235,77 +233,6 @@ class _MapManagementScreenState extends State { } } - Future _exportMaps() async { - if (!mounted) return; - setState(() => _isLoading = true); - try { - final exportPath = await widget.tileCacheService.exportCache(); - if (!mounted) return; - setState(() => _isLoading = false); - - if (mounted) { - await Share.shareXFiles( - [XFile(exportPath)], - subject: 'MeshCore SAR Maps Export', - text: 'Offline maps export from MeshCore SAR', - ); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Maps exported to: $exportPath'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 5), - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() => _isLoading = false); - _showError('Export failed: $e'); - } - } - - Future _importMaps() async { - try { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['fmtc'], - allowMultiple: false, - ); - - if (result == null || result.files.isEmpty) { - return; - } - - if (!mounted) return; - setState(() => _isLoading = true); - - final filePath = result.files.first.path; - if (filePath == null) { - throw Exception('Invalid file path'); - } - - await widget.tileCacheService.importCache(filePath); - - if (!mounted) return; - setState(() => _isLoading = false); - await _loadCacheStats(); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Maps imported successfully!'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (!mounted) return; - setState(() => _isLoading = false); - _showError('Import failed: $e'); - } - } - Future _clearCache() async { final confirmed = await showDialog( context: context, @@ -385,7 +312,7 @@ class _MapManagementScreenState extends State { _buildDownloadCard(), const SizedBox(height: 16), - // Import/Export/Clear + // Clear Cache _buildActionsCard(), ], ), @@ -708,33 +635,11 @@ class _MapManagementScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Map Actions', + 'Cache Management', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), - // Export Button - ElevatedButton.icon( - onPressed: _isDownloading ? null : _exportMaps, - icon: const Icon(Icons.upload), - label: const Text('Export Maps'), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 8), - - // Import Button - ElevatedButton.icon( - onPressed: _isDownloading ? null : _importMaps, - icon: const Icon(Icons.download), - label: const Text('Import Maps'), - style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), - const SizedBox(height: 8), - // Clear Cache Button OutlinedButton.icon( onPressed: _isDownloading ? null : _clearCache, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 8ad8f24..d0cae89 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -26,6 +26,15 @@ class _MessagesTabState extends State { int _characterCount = 0; static const int _maxCharacters = 160; + /// Helper method to compare two public keys for equality + bool _publicKeysMatch(Uint8List key1, Uint8List key2) { + if (key1.length != key2.length) return false; + for (int i = 0; i < key1.length; i++) { + if (key1[i] != key2[i]) return false; + } + return true; + } + @override void initState() { super.initState(); @@ -215,11 +224,19 @@ class _MessagesTabState extends State { // Add to messages list with "sending" status messagesProvider.addSentMessage(sentMessage); + // Look up the room contact for path logging + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= roomPublicKey!.length && + _publicKeysMatch(c.publicKey, roomPublicKey!); + }).firstOrNull; + // Send SAR message to selected room (persisted and immutable) final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: roomPublicKey!, text: fullMessage, messageId: messageId, // Pass message ID so it can be tracked + contact: roomContact, // Include contact for path status logging ); if (!sentSuccessfully) { @@ -407,6 +424,15 @@ class _MessageBubble extends StatelessWidget { this.onTap, }); + /// Helper method to compare two public keys for equality + bool _publicKeysMatch(Uint8List key1, Uint8List key2) { + if (key1.length != key2.length) return false; + for (int i = 0; i < key1.length; i++) { + if (key1[i] != key2[i]) return false; + } + return true; + } + Future _retryFailedMessage(BuildContext context, Message failedMessage) async { final connectionProvider = context.read(); final messagesProvider = context.read(); @@ -448,11 +474,19 @@ class _MessageBubble extends StatelessWidget { return; } + // Look up the room contact for path logging + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= failedMessage.recipientPublicKey!.length && + _publicKeysMatch(c.publicKey, failedMessage.recipientPublicKey!); + }).firstOrNull; + // Resend to the same room final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: failedMessage.recipientPublicKey!, text: failedMessage.text, messageId: retryMessageId, + contact: roomContact, // Include contact for path status logging ); if (!sentSuccessfully) { diff --git a/lib/services/contact_storage_service.dart b/lib/services/contact_storage_service.dart new file mode 100644 index 0000000..6681df7 --- /dev/null +++ b/lib/services/contact_storage_service.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/contact.dart'; +import '../models/contact_telemetry.dart'; +import 'package:latlong2/latlong.dart'; + +/// Service for persisting contacts to local storage +class ContactStorageService { + static const String _contactsKey = 'stored_contacts'; + static const int _maxStoredContacts = 500; // Store up to 500 contacts + + /// Save contacts to persistent storage + Future saveContacts(List contacts) async { + try { + final prefs = await SharedPreferences.getInstance(); + + // Convert contacts to JSON + final jsonList = contacts.map((contact) => _contactToJson(contact)).toList(); + + // Limit to max stored contacts (keep most recent) + final limitedList = jsonList.length > _maxStoredContacts + ? jsonList.sublist(jsonList.length - _maxStoredContacts) + : jsonList; + + final jsonString = jsonEncode(limitedList); + await prefs.setString(_contactsKey, jsonString); + + print('✅ [ContactStorage] Saved ${limitedList.length} contacts to storage'); + } catch (e) { + print('❌ [ContactStorage] Error saving contacts: $e'); + } + } + + /// Load contacts from persistent storage + /// [excludePublicKey] - optional public key to exclude (e.g., device's own key) + Future> loadContacts({Uint8List? excludePublicKey}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_contactsKey); + + if (jsonString == null || jsonString.isEmpty) { + print('â„šī¸ [ContactStorage] No stored contacts found'); + return []; + } + + final jsonList = jsonDecode(jsonString) as List; + final contacts = jsonList + .map((json) => _contactFromJson(json as Map)) + .where((contact) => contact != null) + .cast() + .toList(); + + // Filter out contacts with the excluded public key + final filteredContacts = excludePublicKey != null + ? contacts.where((contact) { + final matches = _publicKeysMatch(contact.publicKey, excludePublicKey); + if (matches) { + print('â„šī¸ [ContactStorage] Excluding contact with matching public key: ${contact.advName}'); + } + return !matches; + }).toList() + : contacts; + + print('✅ [ContactStorage] Loaded ${filteredContacts.length} contacts from storage' + '${excludePublicKey != null ? ' (${contacts.length - filteredContacts.length} excluded)' : ''}'); + return filteredContacts; + } catch (e) { + print('❌ [ContactStorage] Error loading contacts: $e'); + return []; + } + } + + /// Compare two public keys for equality + bool _publicKeysMatch(Uint8List key1, Uint8List key2) { + if (key1.length != key2.length) return false; + for (int i = 0; i < key1.length; i++) { + if (key1[i] != key2[i]) return false; + } + return true; + } + + /// Clear all stored contacts + Future clearContacts() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_contactsKey); + print('✅ [ContactStorage] Cleared all stored contacts'); + } catch (e) { + print('❌ [ContactStorage] Error clearing contacts: $e'); + } + } + + /// Get storage statistics + Future> getStorageStats() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_contactsKey); + + if (jsonString == null || jsonString.isEmpty) { + return { + 'contactCount': 0, + 'storageSizeBytes': 0, + 'storageSizeKB': 0, + }; + } + + final sizeBytes = jsonString.length; + final jsonList = jsonDecode(jsonString) as List; + + return { + 'contactCount': jsonList.length, + 'storageSizeBytes': sizeBytes, + 'storageSizeKB': (sizeBytes / 1024).toStringAsFixed(2), + }; + } catch (e) { + print('❌ [ContactStorage] Error getting storage stats: $e'); + return { + 'contactCount': 0, + 'storageSizeBytes': 0, + 'storageSizeKB': 0, + }; + } + } + + /// Convert Contact to JSON + Map _contactToJson(Contact contact) { + return { + 'publicKey': base64Encode(contact.publicKey), + 'type': contact.type.value, + 'flags': contact.flags, + 'outPathLen': contact.outPathLen, + 'outPath': base64Encode(contact.outPath), + 'advName': contact.advName, + 'lastAdvert': contact.lastAdvert, + 'advLat': contact.advLat, + 'advLon': contact.advLon, + 'lastMod': contact.lastMod, + 'telemetry': contact.telemetry != null ? _telemetryToJson(contact.telemetry!) : null, + }; + } + + /// Convert JSON to Contact + Contact? _contactFromJson(Map json) { + try { + return Contact( + 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, + outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)), + advName: json['advName'] as String, + lastAdvert: json['lastAdvert'] as int, + advLat: json['advLat'] as int, + advLon: json['advLon'] as int, + lastMod: json['lastMod'] as int, + telemetry: json['telemetry'] != null + ? _telemetryFromJson(json['telemetry'] as Map) + : null, + ); + } catch (e) { + print('❌ [ContactStorage] Error parsing contact from JSON: $e'); + return null; + } + } + + /// Convert ContactTelemetry to JSON + Map _telemetryToJson(ContactTelemetry telemetry) { + return { + 'gpsLocation': telemetry.gpsLocation != null + ? { + 'latitude': telemetry.gpsLocation!.latitude, + 'longitude': telemetry.gpsLocation!.longitude, + } + : null, + 'batteryPercentage': telemetry.batteryPercentage, + 'batteryMilliVolts': telemetry.batteryMilliVolts, + 'temperature': telemetry.temperature, + 'humidity': telemetry.humidity, + 'pressure': telemetry.pressure, + 'timestampMillis': telemetry.timestamp.millisecondsSinceEpoch, + 'extraSensorData': telemetry.extraSensorData, + }; + } + + /// Convert JSON to ContactTelemetry + ContactTelemetry? _telemetryFromJson(Map json) { + try { + return ContactTelemetry( + gpsLocation: json['gpsLocation'] != null + ? LatLng( + json['gpsLocation']['latitude'] as double, + json['gpsLocation']['longitude'] as double, + ) + : null, + batteryPercentage: json['batteryPercentage'] as double?, + batteryMilliVolts: json['batteryMilliVolts'] as double?, + temperature: json['temperature'] as double?, + humidity: json['humidity'] as double?, + pressure: json['pressure'] as double?, + timestamp: DateTime.fromMillisecondsSinceEpoch( + json['timestampMillis'] as int), + extraSensorData: json['extraSensorData'] as Map?, + ); + } catch (e) { + print('❌ [ContactStorage] Error parsing telemetry from JSON: $e'); + return null; + } + } +} diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index 24d8cf8..991bbfb 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -1,8 +1,6 @@ -import 'dart:io'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:flutter_map_tile_caching/custom_backend_api.dart'; -import 'package:path_provider/path_provider.dart'; import '../models/map_layer.dart'; class TileCacheService { @@ -124,42 +122,6 @@ class TileCacheService { return stats / (1024 * 1024); } - Future exportCache() async { - if (!_isInitialized) { - throw StateError('TileCacheService not initialized. Call initialize() first.'); - } - - final directory = await getApplicationDocumentsDirectory(); - final timestamp = DateTime.now().millisecondsSinceEpoch; - final exportPath = '${directory.path}/meshcore_maps_$timestamp.fmtc'; - - // Export using FMTCBackendAccess - await FMTCBackendAccess.internal.exportStores( - storeNames: [_storeName], - path: exportPath, - ); - - return exportPath; - } - - Future importCache(String filePath) async { - if (!_isInitialized) { - throw StateError('TileCacheService not initialized. Call initialize() first.'); - } - - final file = File(filePath); - if (!await file.exists()) { - throw Exception('Import file not found: $filePath'); - } - - // Import using FMTCBackendAccess - await FMTCBackendAccess.internal.importStores( - storeNames: [_storeName], - path: filePath, - strategy: ImportConflictStrategy.rename, - ); - } - Future> getAvailableStores() async { if (!_isInitialized) { throw StateError('TileCacheService not initialized. Call initialize() first.'); diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart index 73675ea..2523288 100644 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ b/lib/widgets/contacts/direct_message_sheet.dart @@ -56,10 +56,11 @@ class _DirectMessageSheetState extends State { } try { - // Send direct message to contact + // Send direct message to contact (include contact for path logging) await connectionProvider.sendTextMessage( contactPublicKey: widget.contact.publicKey, text: text, + contact: widget.contact, ); _textController.clear(); @@ -88,48 +89,52 @@ class _DirectMessageSheetState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Container( height: MediaQuery.of(context).size.height * 0.9, - decoration: const BoxDecoration( - color: Color(0xFF1E1E1E), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( 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: const Icon(Icons.arrow_back, color: Colors.white), + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), Expanded( child: Column( children: [ - const Text( + Text( 'Direct Message', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), ), Text( widget.contact.displayName, - style: const TextStyle( - color: Colors.grey, + style: TextStyle( + color: colorScheme.onSurfaceVariant, fontSize: 14, ), ), ], ), ), - IconButton( - icon: const Icon(Icons.more_vert, color: Colors.white), - onPressed: () {}, - ), + const SizedBox(width: 48), // Spacer to keep title centered ], ), ), @@ -171,8 +176,8 @@ class _DirectMessageSheetState extends State { top: 16, bottom: 16 + MediaQuery.of(context).viewInsets.bottom, ), - decoration: const BoxDecoration( - color: Color(0xFF2D2D2D), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, ), child: Column( children: [ @@ -183,21 +188,21 @@ class _DirectMessageSheetState extends State { maxLines: 3, autofocus: true, maxLengthEnforcement: MaxLengthEnforcement.enforced, - style: const TextStyle(color: Colors.white), + style: TextStyle(color: colorScheme.onSurface), decoration: InputDecoration( hintText: 'Type your message...', - hintStyle: const TextStyle(color: Colors.grey), + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), + borderSide: BorderSide(color: colorScheme.outline), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.grey), + borderSide: BorderSide(color: colorScheme.outline), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Colors.white), + borderSide: BorderSide(color: colorScheme.primary, width: 2), ), contentPadding: const EdgeInsets.all(16), counterText: _characterCount >= 150 @@ -207,7 +212,7 @@ class _DirectMessageSheetState extends State { fontSize: 11, color: _characterCount > _maxCharacters * 0.9 ? Colors.orange - : Colors.grey, + : colorScheme.onSurfaceVariant, ), ), textInputAction: TextInputAction.send, diff --git a/lib/widgets/messages/sar_update_sheet.dart b/lib/widgets/messages/sar_update_sheet.dart index 2901707..406e702 100644 --- a/lib/widgets/messages/sar_update_sheet.dart +++ b/lib/widgets/messages/sar_update_sheet.dart @@ -139,31 +139,37 @@ class _SarUpdateSheetState extends State { Widget build(BuildContext context) { // Get keyboard height to adjust padding final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return Container( height: MediaQuery.of(context).size.height * 0.9, - decoration: const BoxDecoration( - color: Color(0xFF1E1E1E), - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( 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: const Icon(Icons.arrow_back, color: Colors.white), + icon: Icon(Icons.arrow_back, color: colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), - const Expanded( + Expanded( child: Column( children: [ Text( 'Send SAR Marker', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), @@ -171,17 +177,14 @@ class _SarUpdateSheetState extends State { Text( 'Quick location marker', style: TextStyle( - color: Colors.grey, + color: colorScheme.onSurfaceVariant, fontSize: 14, ), ), ], ), ), - IconButton( - icon: const Icon(Icons.more_vert, color: Colors.white), - onPressed: () {}, - ), + const SizedBox(width: 48), // Spacer to keep title centered ], ), ), @@ -198,10 +201,10 @@ class _SarUpdateSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Marker type selection - const Text( + Text( 'Marker Type', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -233,10 +236,10 @@ class _SarUpdateSheetState extends State { const SizedBox(height: 24), // Destination selection (compact dropdown with rooms and channel) - const Text( + Text( 'Send To', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -279,29 +282,33 @@ class _SarUpdateSheetState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), decoration: BoxDecoration( - color: const Color(0xFF2D2D2D), + 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: const Row( + hint: Row( children: [ - Icon(Icons.arrow_drop_down_circle, size: 18, color: Colors.grey), - SizedBox(width: 12), + Icon(Icons.arrow_drop_down_circle, size: 18, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 12), Text( 'Select destination...', - style: TextStyle(color: Colors.grey), + style: TextStyle(color: colorScheme.onSurfaceVariant), ), ], ), - dropdownColor: const Color(0xFF2D2D2D), + dropdownColor: colorScheme.surfaceContainerHighest, isExpanded: true, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: colorScheme.onSurface, fontSize: 14, ), - icon: const Icon(Icons.arrow_drop_down, color: Colors.white), + icon: Icon(Icons.arrow_drop_down, color: colorScheme.onSurface), items: destinations.map((contact) { return DropdownMenuItem( value: contact, @@ -310,7 +317,7 @@ class _SarUpdateSheetState extends State { Icon( contact.isChannel ? Icons.public : Icons.storage, size: 18, - color: Colors.white, + color: colorScheme.onSurface, ), const SizedBox(width: 12), Expanded( @@ -381,10 +388,10 @@ class _SarUpdateSheetState extends State { // Location display Row( children: [ - const Text( + Text( 'Location', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -418,23 +425,23 @@ class _SarUpdateSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF2D2D2D), + color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), - child: const Row( + child: Row( children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), + valueColor: AlwaysStoppedAnimation(colorScheme.primary), ), ), - SizedBox(width: 16), + const SizedBox(width: 16), Text( 'Getting location...', - style: TextStyle(color: Colors.white), + style: TextStyle(color: colorScheme.onSurface), ), ], ), @@ -489,7 +496,7 @@ class _SarUpdateSheetState extends State { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFF2D2D2D), + color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Column( @@ -506,18 +513,18 @@ class _SarUpdateSheetState extends State { Expanded( child: Text( '${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}', - style: const TextStyle( + style: TextStyle( fontFamily: 'monospace', fontSize: 13, fontWeight: FontWeight.w500, - color: Colors.white, + color: colorScheme.onSurface, ), ), ), // Only show refresh button if location updates are allowed if (widget.allowLocationUpdate) IconButton( - icon: const Icon(Icons.refresh, size: 20, color: Colors.white), + icon: Icon(Icons.refresh, size: 20, color: colorScheme.onSurface), onPressed: _getCurrentLocation, padding: EdgeInsets.zero, constraints: const BoxConstraints(), @@ -529,17 +536,17 @@ class _SarUpdateSheetState extends State { const SizedBox(height: 8), Row( children: [ - const Icon( + Icon( Icons.my_location, size: 14, - color: Colors.grey, + color: colorScheme.onSurfaceVariant, ), const SizedBox(width: 6), Text( 'Accuracy: Âą${_currentPosition!.accuracy!.round()}m', - style: const TextStyle( + style: TextStyle( fontSize: 12, - color: Colors.grey, + color: colorScheme.onSurfaceVariant, ), ), ], @@ -551,10 +558,10 @@ class _SarUpdateSheetState extends State { const SizedBox(height: 24), // Optional notes - const Text( + Text( 'Notes (optional)', style: TextStyle( - color: Colors.white, + color: colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.bold, ), @@ -564,12 +571,12 @@ class _SarUpdateSheetState extends State { controller: _notesController, maxLines: 3, maxLength: 100, - style: const TextStyle(fontSize: 14, color: Colors.white), + style: TextStyle(fontSize: 14, color: colorScheme.onSurface), decoration: InputDecoration( hintText: 'Add additional information...', - hintStyle: const TextStyle(fontSize: 14, color: Colors.grey), + hintStyle: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant), filled: true, - fillColor: const Color(0xFF2D2D2D), + fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, @@ -726,6 +733,8 @@ class MarkerTypeChip extends StatelessWidget { @override Widget build(BuildContext context) { final color = _getMarkerColor(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; return InkWell( onTap: onTap, @@ -734,10 +743,13 @@ class MarkerTypeChip extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: const Color(0xFF2D2D2D), + color: colorScheme.surfaceContainerHighest, border: isSelected ? Border.all(color: color, width: 2) - : null, + : Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -753,7 +765,7 @@ class MarkerTypeChip extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: isSelected ? color : Colors.white, + color: isSelected ? color : colorScheme.onSurface, ), ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0baa484..11ab6c9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,7 +5,6 @@ import FlutterMacOS import Foundation -import file_picker import flutter_blue_plus_darwin import geolocator_apple import objectbox_flutter_libs @@ -15,7 +14,6 @@ import share_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 3308880..36107a6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,14 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - file_picker: - dependency: "direct main" - description: - name: file_picker - sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 - url: "https://pub.dev" - source: hosted - version: "8.3.7" fixnum: dependency: transitive description: @@ -270,14 +262,6 @@ packages: url: "https://pub.dev" source: hosted version: "10.1.1" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 - url: "https://pub.dev" - source: hosted - version: "2.0.31" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3734584..a26d8a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,7 +56,6 @@ dependencies: flutter_compass: ^0.8.0 # File handling - file_picker: ^8.1.4 share_plus: ^10.1.3 path_provider: ^2.1.5