mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +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 = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||||
@@ -529,7 +529,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||||
@@ -545,7 +545,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp.RunnerTests;
|
||||||
@@ -676,7 +676,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -699,7 +699,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 2;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -5,24 +5,7 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000204">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000355">
|
||||||
|
|
||||||
</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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
providers: [
|
providers: [
|
||||||
// Core providers
|
// Core providers
|
||||||
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
|
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(
|
ChangeNotifierProvider(
|
||||||
create: (_) {
|
create: (_) {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
|
|||||||
@@ -203,6 +203,41 @@ class Contact {
|
|||||||
return advName.substring(emoji.length).trim();
|
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({
|
Contact copyWith({
|
||||||
Uint8List? publicKey,
|
Uint8List? publicKey,
|
||||||
ContactType? type,
|
ContactType? type,
|
||||||
|
|||||||
@@ -42,12 +42,20 @@ class AppProvider with ChangeNotifier {
|
|||||||
void _setupCallbacks() {
|
void _setupCallbacks() {
|
||||||
// When a contact is received from BLE
|
// When a contact is received from BLE
|
||||||
connectionProvider.onContactReceived = (contact) {
|
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
|
// When all contacts are received
|
||||||
connectionProvider.onContactsComplete = (contacts) {
|
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');
|
debugPrint('Received ${contacts.length} contacts');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,6 +126,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
if (!connectionProvider.deviceInfo.isConnected) return;
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|
||||||
try {
|
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
|
// Sync device time
|
||||||
await connectionProvider.syncDeviceTime();
|
await connectionProvider.syncDeviceTime();
|
||||||
|
|
||||||
|
|||||||
@@ -463,10 +463,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// only that it was queued on the companion radio.
|
/// only that it was queued on the companion radio.
|
||||||
///
|
///
|
||||||
/// [messageId] - optional message ID to track delivery status
|
/// [messageId] - optional message ID to track delivery status
|
||||||
|
/// [contact] - optional contact object for path status logging
|
||||||
Future<bool> sendTextMessage({
|
Future<bool> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required String text,
|
required String text,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
Contact? contact,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_bleService.isConnected) {
|
if (!_bleService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
@@ -475,6 +477,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// IMPORTANT: Track pending message BEFORE sending to avoid race condition
|
||||||
// The SENT response can arrive so quickly that if we track after sending,
|
// 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.
|
// 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 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/contact_telemetry.dart';
|
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
|
import '../services/contact_storage_service.dart';
|
||||||
|
|
||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// Add default public channel on initialization
|
// Add default public channel on initialization
|
||||||
ContactsProvider() {
|
ContactsProvider() {
|
||||||
_ensurePublicChannelExists();
|
_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
|
/// Ensure public channel always exists in the list
|
||||||
void _ensurePublicChannelExists() {
|
void _ensurePublicChannelExists() {
|
||||||
const publicChannelKey = 'public_channel_0';
|
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 contacts => _contacts.values.toList();
|
||||||
|
|
||||||
List<Contact> get chatContacts =>
|
List<Contact> get chatContacts =>
|
||||||
@@ -71,16 +117,45 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Add or update a contact
|
/// 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;
|
_contacts[contact.publicKeyHex] = contact;
|
||||||
|
_persistContacts();
|
||||||
notifyListeners();
|
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
|
/// 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) {
|
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;
|
_contacts[contact.publicKeyHex] = contact;
|
||||||
}
|
}
|
||||||
|
if (excluded > 0) {
|
||||||
|
print('ℹ️ [ContactsProvider] Excluded $excluded contact(s) matching device public key');
|
||||||
|
}
|
||||||
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +172,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
// Update contact with new telemetry
|
// Update contact with new telemetry
|
||||||
final updatedContact = contact.copyWith(telemetry: telemetry);
|
final updatedContact = contact.copyWith(telemetry: telemetry);
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to parse telemetry: $e');
|
debugPrint('Failed to parse telemetry: $e');
|
||||||
@@ -151,15 +227,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
/// Clear all contacts
|
/// Clear all contacts
|
||||||
void clearContacts() {
|
void clearContacts() {
|
||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a contact
|
/// Remove a contact
|
||||||
void removeContact(String publicKeyHex) {
|
void removeContact(String publicKeyHex) {
|
||||||
_contacts.remove(publicKeyHex);
|
_contacts.remove(publicKeyHex);
|
||||||
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get storage statistics
|
||||||
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
|
return await _storageService.getStorageStats();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get contact count by type
|
/// Get contact count by type
|
||||||
Map<String, int> get contactCounts {
|
Map<String, int> get contactCounts {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.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/tile_cache_service.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../models/map_layer.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 {
|
Future<void> _clearCache() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -385,7 +312,7 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
|||||||
_buildDownloadCard(),
|
_buildDownloadCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Import/Export/Clear
|
// Clear Cache
|
||||||
_buildActionsCard(),
|
_buildActionsCard(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -708,33 +635,11 @@ class _MapManagementScreenState extends State<MapManagementScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Map Actions',
|
'Cache Management',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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
|
// Clear Cache Button
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _isDownloading ? null : _clearCache,
|
onPressed: _isDownloading ? null : _clearCache,
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
int _characterCount = 0;
|
int _characterCount = 0;
|
||||||
static const int _maxCharacters = 160;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -215,11 +224,19 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
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)
|
// Send SAR message to selected room (persisted and immutable)
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: roomPublicKey!,
|
contactPublicKey: roomPublicKey!,
|
||||||
text: fullMessage,
|
text: fullMessage,
|
||||||
messageId: messageId, // Pass message ID so it can be tracked
|
messageId: messageId, // Pass message ID so it can be tracked
|
||||||
|
contact: roomContact, // Include contact for path status logging
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sentSuccessfully) {
|
if (!sentSuccessfully) {
|
||||||
@@ -407,6 +424,15 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
this.onTap,
|
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 {
|
Future<void> _retryFailedMessage(BuildContext context, Message failedMessage) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
@@ -448,11 +474,19 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
return;
|
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
|
// Resend to the same room
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||||
text: failedMessage.text,
|
text: failedMessage.text,
|
||||||
messageId: retryMessageId,
|
messageId: retryMessageId,
|
||||||
|
contact: roomContact, // Include contact for path status logging
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!sentSuccessfully) {
|
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/flutter_map.dart';
|
||||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
||||||
import 'package:flutter_map_tile_caching/custom_backend_api.dart';
|
import 'package:flutter_map_tile_caching/custom_backend_api.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import '../models/map_layer.dart';
|
import '../models/map_layer.dart';
|
||||||
|
|
||||||
class TileCacheService {
|
class TileCacheService {
|
||||||
@@ -124,42 +122,6 @@ class TileCacheService {
|
|||||||
return stats / (1024 * 1024);
|
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 {
|
Future<List<String>> getAvailableStores() async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
throw StateError('TileCacheService not initialized. Call initialize() first.');
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send direct message to contact
|
// Send direct message to contact (include contact for path logging)
|
||||||
await connectionProvider.sendTextMessage(
|
await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: widget.contact.publicKey,
|
contactPublicKey: widget.contact.publicKey,
|
||||||
text: text,
|
text: text,
|
||||||
|
contact: widget.contact,
|
||||||
);
|
);
|
||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
@@ -88,48 +89,52 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: MediaQuery.of(context).size.height * 0.9,
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF1E1E1E),
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Direct Message',
|
'Direct Message',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
widget.contact.displayName,
|
widget.contact.displayName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 48), // Spacer to keep title centered
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -171,8 +176,8 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
top: 16,
|
top: 16,
|
||||||
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF2D2D2D),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -183,21 +188,21 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Type your message...',
|
hintText: 'Type your message...',
|
||||||
hintStyle: const TextStyle(color: Colors.grey),
|
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Colors.grey),
|
borderSide: BorderSide(color: colorScheme.outline),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Colors.grey),
|
borderSide: BorderSide(color: colorScheme.outline),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Colors.white),
|
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
counterText: _characterCount >= 150
|
counterText: _characterCount >= 150
|
||||||
@@ -207,7 +212,7 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: _characterCount > _maxCharacters * 0.9
|
color: _characterCount > _maxCharacters * 0.9
|
||||||
? Colors.orange
|
? Colors.orange
|
||||||
: Colors.grey,
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
|
|||||||
@@ -139,31 +139,37 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Get keyboard height to adjust padding
|
// Get keyboard height to adjust padding
|
||||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: MediaQuery.of(context).size.height * 0.9,
|
height: MediaQuery.of(context).size.height * 0.9,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFF1E1E1E),
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Send SAR Marker',
|
'Send SAR Marker',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -171,17 +177,14 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Text(
|
Text(
|
||||||
'Quick location marker',
|
'Quick location marker',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 48), // Spacer to keep title centered
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -198,10 +201,10 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Marker type selection
|
// Marker type selection
|
||||||
const Text(
|
Text(
|
||||||
'Marker Type',
|
'Marker Type',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -233,10 +236,10 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Destination selection (compact dropdown with rooms and channel)
|
// Destination selection (compact dropdown with rooms and channel)
|
||||||
const Text(
|
Text(
|
||||||
'Send To',
|
'Send To',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -279,29 +282,33 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2D2D2D),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<Contact>(
|
child: DropdownButton<Contact>(
|
||||||
value: _selectedContact,
|
value: _selectedContact,
|
||||||
hint: const Row(
|
hint: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.arrow_drop_down_circle, size: 18, color: Colors.grey),
|
Icon(Icons.arrow_drop_down_circle, size: 18, color: colorScheme.onSurfaceVariant),
|
||||||
SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Select destination...',
|
'Select destination...',
|
||||||
style: TextStyle(color: Colors.grey),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
dropdownColor: const Color(0xFF2D2D2D),
|
dropdownColor: colorScheme.surfaceContainerHighest,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 14,
|
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) {
|
items: destinations.map((contact) {
|
||||||
return DropdownMenuItem<Contact>(
|
return DropdownMenuItem<Contact>(
|
||||||
value: contact,
|
value: contact,
|
||||||
@@ -310,7 +317,7 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Icon(
|
Icon(
|
||||||
contact.isChannel ? Icons.public : Icons.storage,
|
contact.isChannel ? Icons.public : Icons.storage,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -381,10 +388,10 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
// Location display
|
// Location display
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Location',
|
'Location',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -418,23 +425,23 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2D2D2D),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Text(
|
Text(
|
||||||
'Getting location...',
|
'Getting location...',
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -489,7 +496,7 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2D2D2D),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -506,18 +513,18 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}',
|
'${_currentPosition!.latitude.toStringAsFixed(5)}, ${_currentPosition!.longitude.toStringAsFixed(5)}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Only show refresh button if location updates are allowed
|
// Only show refresh button if location updates are allowed
|
||||||
if (widget.allowLocationUpdate)
|
if (widget.allowLocationUpdate)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh, size: 20, color: Colors.white),
|
icon: Icon(Icons.refresh, size: 20, color: colorScheme.onSurface),
|
||||||
onPressed: _getCurrentLocation,
|
onPressed: _getCurrentLocation,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
constraints: const BoxConstraints(),
|
constraints: const BoxConstraints(),
|
||||||
@@ -529,17 +536,17 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.my_location,
|
Icons.my_location,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Colors.grey,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
'Accuracy: ±${_currentPosition!.accuracy!.round()}m',
|
'Accuracy: ±${_currentPosition!.accuracy!.round()}m',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -551,10 +558,10 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Optional notes
|
// Optional notes
|
||||||
const Text(
|
Text(
|
||||||
'Notes (optional)',
|
'Notes (optional)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -564,12 +571,12 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
|
|||||||
controller: _notesController,
|
controller: _notesController,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
maxLength: 100,
|
maxLength: 100,
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
style: TextStyle(fontSize: 14, color: colorScheme.onSurface),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Add additional information...',
|
hintText: 'Add additional information...',
|
||||||
hintStyle: const TextStyle(fontSize: 14, color: Colors.grey),
|
hintStyle: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF2D2D2D),
|
fillColor: colorScheme.surfaceContainerHighest,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
@@ -726,6 +733,8 @@ class MarkerTypeChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = _getMarkerColor();
|
final color = _getMarkerColor();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
@@ -734,10 +743,13 @@ class MarkerTypeChip extends StatelessWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2D2D2D),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
border: isSelected
|
border: isSelected
|
||||||
? Border.all(color: color, width: 2)
|
? Border.all(color: color, width: 2)
|
||||||
: null,
|
: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -753,7 +765,7 @@ class MarkerTypeChip extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: isSelected ? color : Colors.white,
|
color: isSelected ? color : colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import file_picker
|
|
||||||
import flutter_blue_plus_darwin
|
import flutter_blue_plus_darwin
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
@@ -15,7 +14,6 @@ import share_plus
|
|||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
|
||||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -270,14 +262,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.1.1"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ dependencies:
|
|||||||
flutter_compass: ^0.8.0
|
flutter_compass: ^0.8.0
|
||||||
|
|
||||||
# File handling
|
# File handling
|
||||||
file_picker: ^8.1.4
|
|
||||||
share_plus: ^10.1.3
|
share_plus: ^10.1.3
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user