import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../models/contact.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'; 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 type: ContactType.channel, // Channel type (not room!) flags: 0, outPathLen: 0, outPath: Uint8List(64), advName: 'Public Channel', lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, advLat: 0, advLon: 0, lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); } } /// 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 => contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen); List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); List get rooms => contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); List get channels { // Always ensure public channel exists when getting channels _ensurePublicChannelExists(); return contacts.where((c) => c.isChannel).toList()..sort(_sortByLastSeen); } /// Get both rooms and channels (destinations for SAR markers) List get roomsAndChannels { _ensurePublicChannelExists(); return contacts.where((c) => c.isRoom || c.isChannel).toList()..sort(_sortByLastSeen); } /// Get contacts with location (for map display) List get contactsWithLocation => contacts.where((c) => c.displayLocation != null).toList(); /// Get chat contacts with location (team members on map) List get chatContactsWithLocation => chatContacts.where((c) => c.displayLocation != null).toList(); /// Sort contacts by last seen (most recent first) int _sortByLastSeen(Contact a, Contact b) { return b.lastSeenTime.compareTo(a.lastSeenTime); } /// Add or update a 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 /// 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(); } /// Update contact telemetry void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { // Find contact by public key prefix final contact = _findContactByPrefix(publicKeyPrefix); if (contact == null) return; try { // Parse Cayenne LPP data final telemetry = CayenneLppParser.parse(lppData); // 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'); } } /// Find contact by public key prefix (6 bytes) Contact? _findContactByPrefix(Uint8List prefix) { if (prefix.length < 6) return null; final prefixHex = prefix .sublist(0, 6) .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); for (final contact in contacts) { if (contact.publicKeyHex.startsWith(prefixHex)) { return contact; } } return null; } /// Find contact by public key Contact? findContactByKey(Uint8List publicKey) { final keyHex = publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); return _contacts[keyHex]; } /// Find contact by name Contact? findContactByName(String name) { return contacts.firstWhere( (c) => c.advName == name, orElse: () => contacts.first, ); } /// Get contacts with low battery List get lowBatteryContacts { return contacts.where((c) { final battery = c.displayBattery; return battery != null && battery < 20.0; }).toList(); } /// Get recently seen contacts (within last 10 minutes) List get recentlySeenContacts { return contacts.where((c) => c.isRecentlySeen).toList(); } /// 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 { 'chat': chatContacts.length, 'repeater': repeaters.length, 'room': rooms.length, 'total': contacts.length, }; } }