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.
This commit is contained in:
Janez T
2025-10-15 19:20:46 +02:00
parent 3f03b2e789
commit 91d9326804
17 changed files with 501 additions and 255 deletions

View File

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

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>

View File

@@ -5,24 +5,7 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.263084">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="108.884425">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="11.223762">
<failure message="/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [Application Loader Error Output]: The call to the altool completed with a non-zero exit status: 1. This indicates a failure." />
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000355">
</testcase>

View File

@@ -68,7 +68,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
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();

View File

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

View File

@@ -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();

View File

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

View File

@@ -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<String, Contact> _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<void> 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<void> _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<Contact> get contacts => _contacts.values.toList();
List<Contact> 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<Contact> contacts) {
/// Excludes contacts that match the device's own public key
void addContacts(List<Contact> 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<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats();
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {

View File

@@ -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<MapManagementScreen> {
}
}
Future<void> _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<void> _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<void> _clearCache() async {
final confirmed = await showDialog<bool>(
context: context,
@@ -385,7 +312,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
_buildDownloadCard(),
const SizedBox(height: 16),
// Import/Export/Clear
// Clear Cache
_buildActionsCard(),
],
),
@@ -708,33 +635,11 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
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,

View File

@@ -26,6 +26,15 @@ class _MessagesTabState extends State<MessagesTab> {
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<MessagesTab> {
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
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<void> _retryFailedMessage(BuildContext context, Message failedMessage) async {
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
@@ -448,11 +474,19 @@ class _MessageBubble extends StatelessWidget {
return;
}
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
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) {

View File

@@ -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<void> saveContacts(List<Contact> 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<List<Contact>> 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<dynamic>;
final contacts = jsonList
.map((json) => _contactFromJson(json as Map<String, dynamic>))
.where((contact) => contact != null)
.cast<Contact>()
.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<void> 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<Map<String, dynamic>> 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<dynamic>;
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<String, dynamic> _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<String, dynamic> 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<String, dynamic>)
: null,
);
} catch (e) {
print('❌ [ContactStorage] Error parsing contact from JSON: $e');
return null;
}
}
/// Convert ContactTelemetry to JSON
Map<String, dynamic> _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<String, dynamic> 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<String, dynamic>?,
);
} catch (e) {
print('❌ [ContactStorage] Error parsing telemetry from JSON: $e');
return null;
}
}
}

View File

@@ -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<String> 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<void> 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<List<String>> getAvailableStores() async {
if (!_isInitialized) {
throw StateError('TileCacheService not initialized. Call initialize() first.');

View File

@@ -56,10 +56,11 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
}
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<DirectMessageSheet> {
@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<DirectMessageSheet> {
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<DirectMessageSheet> {
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<DirectMessageSheet> {
fontSize: 11,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: Colors.grey,
: colorScheme.onSurfaceVariant,
),
),
textInputAction: TextInputAction.send,

View File

@@ -139,31 +139,37 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<Contact>(
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<Contact>(
value: contact,
@@ -310,7 +317,7 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
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<SarUpdateSheet> {
// 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<SarUpdateSheet> {
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<Color>(Colors.white),
valueColor: AlwaysStoppedAnimation<Color>(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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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<SarUpdateSheet> {
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,
),
),
),

View File

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

View File

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

View File

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