mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in 'Kernel#load' /opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
210
lib/services/contact_storage_service.dart
Normal file
210
lib/services/contact_storage_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
16
pubspec.lock
16
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user