mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 08:50:30 +00:00
feat: Implement command queue for BLE command handling
- Added BleCommandQueue to manage command serialization and responses in BleCommandSender. - Updated writeData, writeDataAndWaitForAck, and writeDataAndWaitForResponse methods to utilize the command queue. - Enhanced BleResponseHandler to complete commands based on responses received from the BLE device. - Introduced new commands for channel management, including getChannel and setChannel. - Created LocationTrailLayer and TrailControls widgets for displaying and managing location trails on the map. - Added PermissionRequestDialog to handle location permission requests on app startup. - Updated LocationTrackingService to allow GPS tracking without a BLE connection.
This commit is contained in:
@@ -4,7 +4,19 @@
|
|||||||
"Bash(magick Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x.png)",
|
"Bash(magick Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x.png)",
|
||||||
"Bash(convert Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x-noalpha.png)",
|
"Bash(convert Icon-App-1024x1024@1x.png -background white -alpha remove -alpha off Icon-App-1024x1024@1x-noalpha.png)",
|
||||||
"Bash(sips -s format jpeg Icon-App-1024x1024@1x.png --out /tmp/icon_flattened.jpg)",
|
"Bash(sips -s format jpeg Icon-App-1024x1024@1x.png --out /tmp/icon_flattened.jpg)",
|
||||||
"Bash(sips -s format png /tmp/icon_flattened.jpg --out Icon-App-1024x1024@1x.png)"
|
"Bash(sips -s format png /tmp/icon_flattened.jpg --out Icon-App-1024x1024@1x.png)",
|
||||||
|
"Bash(flutter analyze lib/services/ble/)",
|
||||||
|
"Bash(flutter analyze lib/models/location_trail.dart lib/providers/map_provider.dart lib/widgets/map/location_trail_layer.dart lib/widgets/map/trail_controls.dart lib/widgets/map/compass/compass_filters.dart)",
|
||||||
|
"Bash(flutter analyze lib/screens/map_tab.dart)",
|
||||||
|
"Bash(flutter gen-l10n)",
|
||||||
|
"Bash(flutter analyze lib/screens/contacts_tab.dart)",
|
||||||
|
"Bash(flutter analyze lib/widgets/permission_request_dialog.dart lib/main.dart)",
|
||||||
|
"Bash(flutter analyze lib/screens/settings_screen.dart)",
|
||||||
|
"Bash(flutter analyze lib/services/location_tracking_service.dart)",
|
||||||
|
"Bash(flutter analyze lib/main.dart lib/screens/home_screen.dart)",
|
||||||
|
"Bash(flutter analyze lib/main.dart)",
|
||||||
|
"Bash(flutter analyze lib/providers/app_provider.dart lib/services/meshcore_ble_service.dart)",
|
||||||
|
"Bash(flutter analyze lib/services/meshcore_ble_service.dart)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
Binary file not shown.
BIN
ios/Runner.ipa
BIN
ios/Runner.ipa
Binary file not shown.
@@ -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 = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
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 = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 28;
|
CURRENT_PROJECT_VERSION = 31;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>28</string>
|
<string>31</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
@@ -60,6 +60,8 @@
|
|||||||
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
|
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
|
||||||
<key>NSMotionUsageDescription</key>
|
<key>NSMotionUsageDescription</key>
|
||||||
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
|
<string>MeshCore SAR needs access to the compass to show your heading direction on the map</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>MeshCore SAR may need access to your photo library to attach images to messages or save map screenshots for documentation during SAR operations</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -5,24 +5,24 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000186">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000218">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.464562">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.276257">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="126.069776">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="134.535005">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="29032.321843">
|
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="9.956839">
|
||||||
|
|
||||||
<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>' undefined method 'refresh!' for nil" />
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@
|
|||||||
"description": "Error when location permission is permanently denied"
|
"description": "Error when location permission is permanently denied"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"locationPermissionRequired": "Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.",
|
||||||
|
"@locationPermissionRequired": {
|
||||||
|
"description": "Message when location permission is needed"
|
||||||
|
},
|
||||||
|
|
||||||
"locationServicesDisabled": "Location services are disabled. Please enable them in Settings.",
|
"locationServicesDisabled": "Location services are disabled. Please enable them in Settings.",
|
||||||
"@locationServicesDisabled": {
|
"@locationServicesDisabled": {
|
||||||
"description": "Error when location services are disabled"
|
"description": "Error when location services are disabled"
|
||||||
@@ -1197,7 +1202,12 @@
|
|||||||
|
|
||||||
"rooms": "Rooms",
|
"rooms": "Rooms",
|
||||||
"@rooms": {
|
"@rooms": {
|
||||||
"description": "Section header for rooms/channels"
|
"description": "Section header for rooms"
|
||||||
|
},
|
||||||
|
|
||||||
|
"channels": "Channels",
|
||||||
|
"@channels": {
|
||||||
|
"description": "Section header for broadcast channels"
|
||||||
},
|
},
|
||||||
|
|
||||||
"cacheStatistics": "Cache Statistics",
|
"cacheStatistics": "Cache Statistics",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
|
|
||||||
"locationPermissionPermanentlyDenied": "Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.",
|
"locationPermissionPermanentlyDenied": "Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.",
|
||||||
|
|
||||||
|
"locationPermissionRequired": "Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.",
|
||||||
|
|
||||||
"locationServicesDisabled": "Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.",
|
"locationServicesDisabled": "Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.",
|
||||||
|
|
||||||
"failedToGetGpsLocation": "Neuspjelo dobivanje GPS lokacije",
|
"failedToGetGpsLocation": "Neuspjelo dobivanje GPS lokacije",
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
|
|
||||||
"sampleDataDescription": "Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje",
|
"sampleDataDescription": "Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje",
|
||||||
|
|
||||||
"loadSampleData": "Učitaj primjer podataka",
|
"loadSampleData": "Učitaj primjer",
|
||||||
|
|
||||||
"clearAllData": "Očisti sve podatke",
|
"clearAllData": "Očisti sve podatke",
|
||||||
|
|
||||||
@@ -405,6 +407,8 @@
|
|||||||
|
|
||||||
"rooms": "Sobe",
|
"rooms": "Sobe",
|
||||||
|
|
||||||
|
"channels": "Kanali",
|
||||||
|
|
||||||
"cacheStatistics": "Statistika predmemorije",
|
"cacheStatistics": "Statistika predmemorije",
|
||||||
|
|
||||||
"totalTiles": "Ukupno pločica",
|
"totalTiles": "Ukupno pločica",
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Location permission permanently denied. Please enable in Settings.'**
|
/// **'Location permission permanently denied. Please enable in Settings.'**
|
||||||
String get locationPermissionPermanentlyDenied;
|
String get locationPermissionPermanentlyDenied;
|
||||||
|
|
||||||
|
/// Message when location permission is needed
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.'**
|
||||||
|
String get locationPermissionRequired;
|
||||||
|
|
||||||
/// Error when location services are disabled
|
/// Error when location services are disabled
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -1311,12 +1317,18 @@ abstract class AppLocalizations {
|
|||||||
/// **'Repeaters'**
|
/// **'Repeaters'**
|
||||||
String get repeaters;
|
String get repeaters;
|
||||||
|
|
||||||
/// Section header for rooms/channels
|
/// Section header for rooms
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Rooms'**
|
/// **'Rooms'**
|
||||||
String get rooms;
|
String get rooms;
|
||||||
|
|
||||||
|
/// Section header for broadcast channels
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Channels'**
|
||||||
|
String get channels;
|
||||||
|
|
||||||
/// Title for cache statistics section
|
/// Title for cache statistics section
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get locationPermissionPermanentlyDenied =>
|
String get locationPermissionPermanentlyDenied =>
|
||||||
'Location permission permanently denied. Please enable in Settings.';
|
'Location permission permanently denied. Please enable in Settings.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get locationPermissionRequired =>
|
||||||
|
'Location permission is required for GPS tracking and team coordination. You can enable it later in Settings.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationServicesDisabled =>
|
String get locationServicesDisabled =>
|
||||||
'Location services are disabled. Please enable them in Settings.';
|
'Location services are disabled. Please enable them in Settings.';
|
||||||
@@ -701,6 +705,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rooms => 'Rooms';
|
String get rooms => 'Rooms';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channels => 'Channels';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheStatistics => 'Cache Statistics';
|
String get cacheStatistics => 'Cache Statistics';
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get locationPermissionPermanentlyDenied =>
|
String get locationPermissionPermanentlyDenied =>
|
||||||
'Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.';
|
'Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get locationPermissionRequired =>
|
||||||
|
'Dopuštenje za lokaciju potrebno je za GPS praćenje i koordinaciju tima. Možete ga omogućiti kasnije u Postavkama.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationServicesDisabled =>
|
String get locationServicesDisabled =>
|
||||||
'Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.';
|
'Usluge lokacije su onemogućene. Molimo omogućite ih u Postavkama.';
|
||||||
@@ -233,7 +237,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
'Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje';
|
'Učitajte ili očistite primjere kontakata, poruka kanala i SAR markera za testiranje';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadSampleData => 'Učitaj primjer podataka';
|
String get loadSampleData => 'Učitaj primjer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get clearAllData => 'Očisti sve podatke';
|
String get clearAllData => 'Očisti sve podatke';
|
||||||
@@ -701,6 +705,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rooms => 'Sobe';
|
String get rooms => 'Sobe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channels => 'Kanali';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheStatistics => 'Statistika predmemorije';
|
String get cacheStatistics => 'Statistika predmemorije';
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get locationPermissionPermanentlyDenied =>
|
String get locationPermissionPermanentlyDenied =>
|
||||||
'Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.';
|
'Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get locationPermissionRequired =>
|
||||||
|
'Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationServicesDisabled =>
|
String get locationServicesDisabled =>
|
||||||
'Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.';
|
'Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.';
|
||||||
@@ -233,7 +237,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
'Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje';
|
'Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loadSampleData => 'Naloži vzorčne podatke';
|
String get loadSampleData => 'Naloži vzorec';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get clearAllData => 'Počisti vse podatke';
|
String get clearAllData => 'Počisti vse podatke';
|
||||||
@@ -701,6 +705,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rooms => 'Sobe';
|
String get rooms => 'Sobe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get channels => 'Kanali';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cacheStatistics => 'Statistika predpomnilnika';
|
String get cacheStatistics => 'Statistika predpomnilnika';
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
|
|
||||||
"locationPermissionPermanentlyDenied": "Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.",
|
"locationPermissionPermanentlyDenied": "Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.",
|
||||||
|
|
||||||
|
"locationPermissionRequired": "Dovoljenje za lokacijo je potrebno za GPS sledenje in usklajevanje ekipe. Lahko ga omogočite kasneje v Nastavitvah.",
|
||||||
|
|
||||||
"locationServicesDisabled": "Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.",
|
"locationServicesDisabled": "Lokacijske storitve so onemogočene. Prosimo, omogočite jih v Nastavitvah.",
|
||||||
|
|
||||||
"failedToGetGpsLocation": "Pridobitev GPS lokacije ni uspela",
|
"failedToGetGpsLocation": "Pridobitev GPS lokacije ni uspela",
|
||||||
@@ -137,7 +139,7 @@
|
|||||||
|
|
||||||
"sampleDataDescription": "Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje",
|
"sampleDataDescription": "Naložite ali počistite vzorčne stike, sporočila kanalov in SAR označevalce za testiranje",
|
||||||
|
|
||||||
"loadSampleData": "Naloži vzorčne podatke",
|
"loadSampleData": "Naloži vzorec",
|
||||||
|
|
||||||
"clearAllData": "Počisti vse podatke",
|
"clearAllData": "Počisti vse podatke",
|
||||||
|
|
||||||
@@ -405,6 +407,8 @@
|
|||||||
|
|
||||||
"rooms": "Sobe",
|
"rooms": "Sobe",
|
||||||
|
|
||||||
|
"channels": "Kanali",
|
||||||
|
|
||||||
"cacheStatistics": "Statistika predpomnilnika",
|
"cacheStatistics": "Statistika predpomnilnika",
|
||||||
|
|
||||||
"totalTiles": "Skupaj ploščic",
|
"totalTiles": "Skupaj ploščic",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'providers/connection_provider.dart';
|
import 'providers/connection_provider.dart';
|
||||||
import 'providers/contacts_provider.dart';
|
import 'providers/contacts_provider.dart';
|
||||||
import 'providers/messages_provider.dart';
|
import 'providers/messages_provider.dart';
|
||||||
import 'providers/map_provider.dart';
|
import 'providers/map_provider.dart';
|
||||||
import 'providers/drawing_provider.dart';
|
import 'providers/drawing_provider.dart';
|
||||||
|
import 'providers/channels_provider.dart';
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
import 'services/tile_cache_service.dart';
|
import 'services/tile_cache_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
@@ -30,6 +32,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
AppThemeMode _themeMode = AppThemeMode.system;
|
AppThemeMode _themeMode = AppThemeMode.system;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _shouldShowPermissionDialog = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -42,11 +45,29 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
await _loadLocalePreference();
|
await _loadLocalePreference();
|
||||||
// Initialize notification service
|
// Initialize notification service
|
||||||
await NotificationService().initialize();
|
await NotificationService().initialize();
|
||||||
|
|
||||||
|
// Check if we need to request location permissions
|
||||||
|
await _checkLocationPermissions();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkLocationPermissions() async {
|
||||||
|
try {
|
||||||
|
final permission = await Geolocator.checkPermission();
|
||||||
|
|
||||||
|
// Show dialog if permission is denied or not determined
|
||||||
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
|
_shouldShowPermissionDialog = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error checking location permissions: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadThemePreference() async {
|
Future<void> _loadThemePreference() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final themeName = prefs.getString('theme_mode') ?? 'system';
|
final themeName = prefs.getString('theme_mode') ?? 'system';
|
||||||
@@ -114,27 +135,30 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
return provider;
|
return provider;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
|
|
||||||
// Tile cache service
|
// Tile cache service
|
||||||
Provider(create: (_) => TileCacheService()),
|
Provider(create: (_) => TileCacheService()),
|
||||||
|
|
||||||
// App provider that coordinates everything
|
// App provider that coordinates everything
|
||||||
ChangeNotifierProxyProvider5<ConnectionProvider, ContactsProvider,
|
ChangeNotifierProxyProvider6<ConnectionProvider, ContactsProvider,
|
||||||
MessagesProvider, DrawingProvider, TileCacheService, AppProvider>(
|
MessagesProvider, DrawingProvider, ChannelsProvider, TileCacheService, AppProvider>(
|
||||||
create: (context) => AppProvider(
|
create: (context) => AppProvider(
|
||||||
connectionProvider: context.read<ConnectionProvider>(),
|
connectionProvider: context.read<ConnectionProvider>(),
|
||||||
contactsProvider: context.read<ContactsProvider>(),
|
contactsProvider: context.read<ContactsProvider>(),
|
||||||
messagesProvider: context.read<MessagesProvider>(),
|
messagesProvider: context.read<MessagesProvider>(),
|
||||||
drawingProvider: context.read<DrawingProvider>(),
|
drawingProvider: context.read<DrawingProvider>(),
|
||||||
|
channelsProvider: context.read<ChannelsProvider>(),
|
||||||
tileCacheService: context.read<TileCacheService>(),
|
tileCacheService: context.read<TileCacheService>(),
|
||||||
),
|
),
|
||||||
update: (context, conn, contacts, messages, drawings, tileCache, previous) =>
|
update: (context, conn, contacts, messages, drawings, channels, tileCache, previous) =>
|
||||||
previous ??
|
previous ??
|
||||||
AppProvider(
|
AppProvider(
|
||||||
connectionProvider: conn,
|
connectionProvider: conn,
|
||||||
contactsProvider: contacts,
|
contactsProvider: contacts,
|
||||||
messagesProvider: messages,
|
messagesProvider: messages,
|
||||||
drawingProvider: drawings,
|
drawingProvider: drawings,
|
||||||
|
channelsProvider: channels,
|
||||||
tileCacheService: tileCache,
|
tileCacheService: tileCache,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -165,6 +189,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
onLocaleChanged: _handleLocaleChanged,
|
onLocaleChanged: _handleLocaleChanged,
|
||||||
currentTheme: _themeMode,
|
currentTheme: _themeMode,
|
||||||
currentLocale: _locale,
|
currentLocale: _locale,
|
||||||
|
shouldShowPermissionDialog: _shouldShowPermissionDialog,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
75
lib/models/channel.dart
Normal file
75
lib/models/channel.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/// Channel model - represents a communication channel
|
||||||
|
class Channel {
|
||||||
|
final int index;
|
||||||
|
final String name;
|
||||||
|
final int? flags;
|
||||||
|
|
||||||
|
Channel({
|
||||||
|
required this.index,
|
||||||
|
required this.name,
|
||||||
|
this.flags,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Display name for the channel
|
||||||
|
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||||
|
String get displayName {
|
||||||
|
if (index == 0) {
|
||||||
|
return name.isEmpty ? 'Public' : name;
|
||||||
|
}
|
||||||
|
return name.isEmpty ? 'Channel $index' : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if channel is the public channel (index 0)
|
||||||
|
bool get isPublicChannel => index == 0;
|
||||||
|
|
||||||
|
/// Check if channel has a custom name
|
||||||
|
bool get hasCustomName => name.isNotEmpty;
|
||||||
|
|
||||||
|
/// Create from JSON
|
||||||
|
factory Channel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Channel(
|
||||||
|
index: json['index'] as int,
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
flags: json['flags'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'index': index,
|
||||||
|
'name': name,
|
||||||
|
'flags': flags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy with modified fields
|
||||||
|
Channel copyWith({
|
||||||
|
int? index,
|
||||||
|
String? name,
|
||||||
|
int? flags,
|
||||||
|
}) {
|
||||||
|
return Channel(
|
||||||
|
index: index ?? this.index,
|
||||||
|
name: name ?? this.name,
|
||||||
|
flags: flags ?? this.flags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Channel(index: $index, name: $name, flags: $flags)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is Channel &&
|
||||||
|
other.index == index &&
|
||||||
|
other.name == name &&
|
||||||
|
other.flags == flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(index, name, flags);
|
||||||
|
}
|
||||||
106
lib/models/location_trail.dart
Normal file
106
lib/models/location_trail.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
/// Represents a single point in a location trail
|
||||||
|
class TrailPoint {
|
||||||
|
final LatLng position;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final double? accuracy;
|
||||||
|
final double? speed;
|
||||||
|
|
||||||
|
TrailPoint({
|
||||||
|
required this.position,
|
||||||
|
required this.timestamp,
|
||||||
|
this.accuracy,
|
||||||
|
this.speed,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'lat': position.latitude,
|
||||||
|
'lon': position.longitude,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'accuracy': accuracy,
|
||||||
|
'speed': speed,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory TrailPoint.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TrailPoint(
|
||||||
|
position: LatLng(json['lat'] as double, json['lon'] as double),
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
accuracy: json['accuracy'] as double?,
|
||||||
|
speed: json['speed'] as double?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a location trail (breadcrumb trail) on the map
|
||||||
|
class LocationTrail {
|
||||||
|
final String id;
|
||||||
|
final List<TrailPoint> points;
|
||||||
|
final DateTime startTime;
|
||||||
|
DateTime? endTime;
|
||||||
|
bool isActive;
|
||||||
|
|
||||||
|
LocationTrail({
|
||||||
|
required this.id,
|
||||||
|
List<TrailPoint>? points,
|
||||||
|
DateTime? startTime,
|
||||||
|
this.endTime,
|
||||||
|
this.isActive = true,
|
||||||
|
}) : points = points ?? [],
|
||||||
|
startTime = startTime ?? DateTime.now();
|
||||||
|
|
||||||
|
/// Add a new point to the trail
|
||||||
|
void addPoint(TrailPoint point) {
|
||||||
|
points.add(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total distance traveled in meters
|
||||||
|
double get totalDistance {
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
|
||||||
|
final distance = Distance();
|
||||||
|
double total = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < points.length - 1; i++) {
|
||||||
|
total += distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
points[i].position,
|
||||||
|
points[i + 1].position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get duration of the trail
|
||||||
|
Duration get duration {
|
||||||
|
if (points.isEmpty) return Duration.zero;
|
||||||
|
final end = endTime ?? DateTime.now();
|
||||||
|
return end.difference(startTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list of LatLng points for rendering
|
||||||
|
List<LatLng> get latLngPoints => points.map((p) => p.position).toList();
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'points': points.map((p) => p.toJson()).toList(),
|
||||||
|
'startTime': startTime.toIso8601String(),
|
||||||
|
'endTime': endTime?.toIso8601String(),
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory LocationTrail.fromJson(Map<String, dynamic> json) {
|
||||||
|
return LocationTrail(
|
||||||
|
id: json['id'] as String,
|
||||||
|
points: (json['points'] as List)
|
||||||
|
.map((p) => TrailPoint.fromJson(p as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
|
endTime: json['endTime'] != null
|
||||||
|
? DateTime.parse(json['endTime'] as String)
|
||||||
|
: null,
|
||||||
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'connection_provider.dart';
|
|||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
import 'messages_provider.dart';
|
import 'messages_provider.dart';
|
||||||
import 'drawing_provider.dart';
|
import 'drawing_provider.dart';
|
||||||
|
import 'channels_provider.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
import '../services/tile_cache_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -16,6 +17,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
final ContactsProvider contactsProvider;
|
final ContactsProvider contactsProvider;
|
||||||
final MessagesProvider messagesProvider;
|
final MessagesProvider messagesProvider;
|
||||||
final DrawingProvider drawingProvider;
|
final DrawingProvider drawingProvider;
|
||||||
|
final ChannelsProvider channelsProvider;
|
||||||
final TileCacheService tileCacheService;
|
final TileCacheService tileCacheService;
|
||||||
final LocationTrackingService locationTrackingService = LocationTrackingService();
|
final LocationTrackingService locationTrackingService = LocationTrackingService();
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
required this.contactsProvider,
|
required this.contactsProvider,
|
||||||
required this.messagesProvider,
|
required this.messagesProvider,
|
||||||
required this.drawingProvider,
|
required this.drawingProvider,
|
||||||
|
required this.channelsProvider,
|
||||||
required this.tileCacheService,
|
required this.tileCacheService,
|
||||||
}) {
|
}) {
|
||||||
_setupCallbacks();
|
_setupCallbacks();
|
||||||
@@ -97,6 +100,12 @@ class AppProvider with ChangeNotifier {
|
|||||||
debugPrint('Received ${contacts.length} contacts');
|
debugPrint('Received ${contacts.length} contacts');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When channel info is received
|
||||||
|
connectionProvider.onChannelInfoReceived = (channelIdx, channelName) {
|
||||||
|
channelsProvider.addOrUpdateChannel(channelIdx, channelName);
|
||||||
|
debugPrint('📻 [AppProvider] Channel $channelIdx: "$channelName"');
|
||||||
|
};
|
||||||
|
|
||||||
// When a message is received
|
// When a message is received
|
||||||
connectionProvider.onMessageReceived = (message) {
|
connectionProvider.onMessageReceived = (message) {
|
||||||
// Check if message is a drawing broadcast
|
// Check if message is a drawing broadcast
|
||||||
@@ -242,6 +251,11 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Small delay to ensure contacts are fully loaded
|
// Small delay to ensure contacts are fully loaded
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Sync all channels to get channel names
|
||||||
|
debugPrint('📻 [AppProvider] Syncing channels...');
|
||||||
|
await connectionProvider.syncChannels();
|
||||||
|
debugPrint('✅ [AppProvider] Channel sync complete');
|
||||||
|
|
||||||
// Automatically login to all saved rooms
|
// Automatically login to all saved rooms
|
||||||
await _autoLoginToRooms();
|
await _autoLoginToRooms();
|
||||||
|
|
||||||
@@ -253,6 +267,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
|
// Note: Future messages are synced automatically via PUSH_CODE_MSG_WAITING events
|
||||||
|
|
||||||
|
// Start location tracking AFTER all initialization is complete
|
||||||
|
debugPrint('📍 [AppProvider] Starting location tracking after successful initialization');
|
||||||
|
await _startLocationTracking();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Initialization error: $e');
|
debugPrint('Initialization error: $e');
|
||||||
@@ -395,11 +413,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
final wasTracking = locationTrackingService.isTracking;
|
final wasTracking = locationTrackingService.isTracking;
|
||||||
|
|
||||||
if (isConnected && !wasTracking) {
|
// Only stop tracking on disconnect - DON'T start on connect
|
||||||
// Connection established - start location tracking
|
// Location tracking will be started AFTER initialization completes
|
||||||
debugPrint('🔵 [AppProvider] BLE connected - starting location tracking');
|
if (!isConnected && wasTracking) {
|
||||||
_startLocationTracking();
|
|
||||||
} else if (!isConnected && wasTracking) {
|
|
||||||
// Connection lost - stop location tracking
|
// Connection lost - stop location tracking
|
||||||
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
|
debugPrint('🔴 [AppProvider] BLE disconnected - stopping location tracking');
|
||||||
_stopLocationTracking();
|
_stopLocationTracking();
|
||||||
|
|||||||
51
lib/providers/channels_provider.dart
Normal file
51
lib/providers/channels_provider.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
|
||||||
|
/// Manages channel information from the MeshCore device
|
||||||
|
class ChannelsProvider with ChangeNotifier {
|
||||||
|
final Map<int, Channel> _channels = {};
|
||||||
|
|
||||||
|
/// Get all channels
|
||||||
|
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||||
|
|
||||||
|
/// Get a specific channel by index
|
||||||
|
Channel? getChannel(int index) => _channels[index];
|
||||||
|
|
||||||
|
/// Get the display name for a channel
|
||||||
|
String getChannelDisplayName(int index) {
|
||||||
|
final channel = _channels[index];
|
||||||
|
if (channel != null) {
|
||||||
|
return channel.displayName;
|
||||||
|
}
|
||||||
|
// Fallback if channel hasn't been synced yet
|
||||||
|
return index == 0 ? 'Public' : 'Channel $index';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a channel
|
||||||
|
void addOrUpdateChannel(int index, String name, {int? flags}) {
|
||||||
|
_channels[index] = Channel(
|
||||||
|
index: index,
|
||||||
|
name: name,
|
||||||
|
flags: flags,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all channels
|
||||||
|
void clear() {
|
||||||
|
_channels.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if channels have been loaded
|
||||||
|
bool get hasChannels => _channels.isNotEmpty;
|
||||||
|
|
||||||
|
/// Get the number of channels
|
||||||
|
int get channelCount => _channels.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_channels.clear();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,6 +123,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Function(List<Contact>)? onContactsComplete;
|
Function(List<Contact>)? onContactsComplete;
|
||||||
Function(Message)? onMessageReceived;
|
Function(Message)? onMessageReceived;
|
||||||
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
|
||||||
|
Function(int channelIdx, String channelName)? onChannelInfoReceived;
|
||||||
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData)?
|
||||||
onBinaryResponse;
|
onBinaryResponse;
|
||||||
Function(Uint8List publicKey)? onPathUpdated;
|
Function(Uint8List publicKey)? onPathUpdated;
|
||||||
@@ -249,6 +250,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onContactsComplete?.call(contacts);
|
onContactsComplete?.call(contacts);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_bleService.onChannelInfoReceived = (channelIdx, channelName) {
|
||||||
|
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||||
|
};
|
||||||
|
|
||||||
_bleService.onMessageReceived = (message) {
|
_bleService.onMessageReceived = (message) {
|
||||||
// Parse SAR markers
|
// Parse SAR markers
|
||||||
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
final enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
@@ -628,6 +633,24 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sync all channels from device
|
||||||
|
Future<void> syncChannels({int? maxChannels}) async {
|
||||||
|
if (!_bleService.isConnected) {
|
||||||
|
_error = 'Not connected to device';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use maxChannels from device info if available, otherwise default to 40
|
||||||
|
final channelCount = maxChannels ?? _deviceInfo.maxChannels ?? 40;
|
||||||
|
await _bleService.syncAllChannels(maxChannels: channelCount);
|
||||||
|
} catch (e) {
|
||||||
|
_error = 'Failed to sync channels: $e';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or update a contact on the companion radio
|
/// Add or update a contact on the companion radio
|
||||||
///
|
///
|
||||||
/// This manually adds a contact to the radio's internal contact table.
|
/// This manually adds a contact to the radio's internal contact table.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import '../models/location_trail.dart';
|
||||||
|
|
||||||
class MapProvider with ChangeNotifier {
|
class MapProvider with ChangeNotifier {
|
||||||
LatLng? _targetLocation;
|
LatLng? _targetLocation;
|
||||||
@@ -9,11 +10,22 @@ class MapProvider with ChangeNotifier {
|
|||||||
// Track which contact paths are currently visible
|
// Track which contact paths are currently visible
|
||||||
final Set<String> _visibleContactPaths = {};
|
final Set<String> _visibleContactPaths = {};
|
||||||
|
|
||||||
|
// Location trail tracking
|
||||||
|
LocationTrail? _currentTrail;
|
||||||
|
bool _isTrailVisible = true;
|
||||||
|
final List<LocationTrail> _trailHistory = [];
|
||||||
|
|
||||||
LatLng? get targetLocation => _targetLocation;
|
LatLng? get targetLocation => _targetLocation;
|
||||||
double? get targetZoom => _targetZoom;
|
double? get targetZoom => _targetZoom;
|
||||||
bool get shouldAnimate => _shouldAnimate;
|
bool get shouldAnimate => _shouldAnimate;
|
||||||
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
||||||
|
|
||||||
|
// Trail getters
|
||||||
|
LocationTrail? get currentTrail => _currentTrail;
|
||||||
|
bool get isTrailVisible => _isTrailVisible;
|
||||||
|
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||||
|
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||||
|
|
||||||
void navigateToLocation({
|
void navigateToLocation({
|
||||||
required LatLng location,
|
required LatLng location,
|
||||||
double zoom = 15.0,
|
double zoom = 15.0,
|
||||||
@@ -64,4 +76,80 @@ class MapProvider with ChangeNotifier {
|
|||||||
_visibleContactPaths.add(publicKeyHex);
|
_visibleContactPaths.add(publicKeyHex);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a new location trail
|
||||||
|
void startTrail() {
|
||||||
|
// End current trail if active
|
||||||
|
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||||
|
endTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentTrail = LocationTrail(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
_isTrailVisible = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a point to the current trail
|
||||||
|
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||||
|
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||||
|
startTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentTrail!.addPoint(TrailPoint(
|
||||||
|
position: position,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
accuracy: accuracy,
|
||||||
|
speed: speed,
|
||||||
|
));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the current trail
|
||||||
|
void endTrail() {
|
||||||
|
if (_currentTrail != null) {
|
||||||
|
_currentTrail!.isActive = false;
|
||||||
|
_currentTrail!.endTime = DateTime.now();
|
||||||
|
if (_currentTrail!.points.isNotEmpty) {
|
||||||
|
_trailHistory.add(_currentTrail!);
|
||||||
|
}
|
||||||
|
_currentTrail = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle trail visibility
|
||||||
|
void toggleTrailVisibility() {
|
||||||
|
_isTrailVisible = !_isTrailVisible;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current trail
|
||||||
|
void clearCurrentTrail() {
|
||||||
|
if (_currentTrail != null) {
|
||||||
|
_currentTrail = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all trail history
|
||||||
|
void clearAllTrails() {
|
||||||
|
_currentTrail = null;
|
||||||
|
_trailHistory.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get total trail distance in meters
|
||||||
|
double get totalTrailDistance {
|
||||||
|
if (_currentTrail == null) return 0;
|
||||||
|
return _currentTrail!.totalDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get trail duration
|
||||||
|
Duration get trailDuration {
|
||||||
|
if (_currentTrail == null) return Duration.zero;
|
||||||
|
return _currentTrail!.duration;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
final chatContacts = contactsProvider.chatContacts;
|
final chatContacts = contactsProvider.chatContacts;
|
||||||
final repeaters = contactsProvider.repeaters;
|
final repeaters = contactsProvider.repeaters;
|
||||||
final rooms = contactsProvider.rooms;
|
final rooms = contactsProvider.rooms;
|
||||||
|
final channels = contactsProvider.channels;
|
||||||
|
|
||||||
if (contactsProvider.contacts.isEmpty) {
|
// Check if there are any displayable contacts (excluding channels)
|
||||||
|
final hasDisplayableContacts = chatContacts.isNotEmpty ||
|
||||||
|
repeaters.isNotEmpty ||
|
||||||
|
rooms.isNotEmpty;
|
||||||
|
|
||||||
|
if (!hasDisplayableContacts) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -160,7 +166,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Rooms/Channels
|
// Rooms
|
||||||
if (rooms.isNotEmpty) ...[
|
if (rooms.isNotEmpty) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
title: l10n.rooms,
|
title: l10n.rooms,
|
||||||
@@ -175,6 +181,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
formatDistance: _formatDistance,
|
formatDistance: _formatDistance,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
if (channels.isNotEmpty) ...[
|
||||||
|
_SectionHeader(
|
||||||
|
title: l10n.channels,
|
||||||
|
count: channels.length,
|
||||||
|
icon: Icons.broadcast_on_personal,
|
||||||
|
),
|
||||||
|
...channels.map(
|
||||||
|
(contact) => ContactTile(
|
||||||
|
contact: contact,
|
||||||
|
currentPosition: _currentPosition,
|
||||||
|
calculateDistance: _calculateDistanceInMeters,
|
||||||
|
formatDistance: _formatDistance,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ import 'device_config_screen.dart';
|
|||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../widgets/permission_request_dialog.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
final Function(Locale?) onLocaleChanged;
|
final Function(Locale?) onLocaleChanged;
|
||||||
final AppThemeMode currentTheme;
|
final AppThemeMode currentTheme;
|
||||||
final Locale? currentLocale;
|
final Locale? currentLocale;
|
||||||
|
final bool shouldShowPermissionDialog;
|
||||||
|
|
||||||
const HomeScreen({
|
const HomeScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -31,6 +33,7 @@ class HomeScreen extends StatefulWidget {
|
|||||||
required this.onLocaleChanged,
|
required this.onLocaleChanged,
|
||||||
required this.currentTheme,
|
required this.currentTheme,
|
||||||
required this.currentLocale,
|
required this.currentLocale,
|
||||||
|
this.shouldShowPermissionDialog = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -58,6 +61,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
|
||||||
|
// Show permission dialog after the first frame if needed
|
||||||
|
if (widget.shouldShowPermissionDialog) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_showPermissionDialog();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
@@ -75,6 +85,34 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showPermissionDialog() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => PermissionRequestDialog(
|
||||||
|
onPermissionsGranted: () {
|
||||||
|
debugPrint('✅ Location permissions granted');
|
||||||
|
},
|
||||||
|
onPermissionsDenied: () {
|
||||||
|
debugPrint('⚠️ Location permissions denied');
|
||||||
|
// Show a snackbar to inform the user
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
AppLocalizations.of(context)!.locationPermissionRequired,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _advertiseDevice(BuildContext context) async {
|
Future<void> _advertiseDevice(BuildContext context) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import '../widgets/map/compass_widget.dart';
|
|||||||
import '../widgets/map/detailed_compass_dialog.dart';
|
import '../widgets/map/detailed_compass_dialog.dart';
|
||||||
import '../widgets/map/drawing_layer.dart';
|
import '../widgets/map/drawing_layer.dart';
|
||||||
import '../widgets/map/drawing_toolbar.dart';
|
import '../widgets/map/drawing_toolbar.dart';
|
||||||
|
import '../widgets/map/location_trail_layer.dart';
|
||||||
|
import '../widgets/map/trail_controls.dart';
|
||||||
import '../widgets/messages/sar_update_sheet.dart';
|
import '../widgets/messages/sar_update_sheet.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'map_management_screen.dart';
|
import 'map_management_screen.dart';
|
||||||
@@ -135,6 +137,16 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
// Position updates trigger UI rebuild for markers
|
// Position updates trigger UI rebuild for markers
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add location point to trail when tracking is active
|
||||||
|
final mapProvider = context.read<MapProvider>();
|
||||||
|
if (_locationService.isTracking) {
|
||||||
|
mapProvider.addTrailPoint(
|
||||||
|
LatLng(position.latitude, position.longitude),
|
||||||
|
accuracy: position.accuracy,
|
||||||
|
speed: position.speed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Rotate map if rotation mode is enabled and heading is available
|
// Rotate map if rotation mode is enabled and heading is available
|
||||||
if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) {
|
if (_isMapReady && _rotateMarkerWithHeading && position.heading >= 0) {
|
||||||
try {
|
try {
|
||||||
@@ -1149,6 +1161,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Location trail layer (rendered after paths, before drawings)
|
||||||
|
const LocationTrailLayer(),
|
||||||
// Drawing layer (rendered after paths, before markers)
|
// Drawing layer (rendered after paths, before markers)
|
||||||
DrawingLayer(
|
DrawingLayer(
|
||||||
drawings: drawingProvider.drawings,
|
drawings: drawingProvider.drawings,
|
||||||
@@ -1339,6 +1353,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
objectCount: messagesProvider.objectMarkers.length,
|
objectCount: messagesProvider.objectMarkers.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Trail stats overlay (top-left, below legend if shown)
|
||||||
|
if (!_isFullscreen)
|
||||||
|
const TrailStatsOverlay(),
|
||||||
// Map controls - right side (hidden in fullscreen mode)
|
// Map controls - right side (hidden in fullscreen mode)
|
||||||
if (!_isFullscreen)
|
if (!_isFullscreen)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -1383,6 +1400,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
child: const Icon(Icons.my_location),
|
child: const Icon(Icons.my_location),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
// Trail controls button
|
||||||
|
const TrailControls(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
FloatingActionButton.small(
|
FloatingActionButton.small(
|
||||||
heroTag: 'layer_selector',
|
heroTag: 'layer_selector',
|
||||||
onPressed: () => _showLayerSelector(context),
|
onPressed: () => _showLayerSelector(context),
|
||||||
|
|||||||
@@ -249,6 +249,89 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLocationPermissionTap() async {
|
||||||
|
try {
|
||||||
|
final permission = await Geolocator.checkPermission();
|
||||||
|
|
||||||
|
if (permission == LocationPermission.deniedForever) {
|
||||||
|
// Show dialog to open app settings
|
||||||
|
if (!mounted) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.settings, size: 24),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Location Permission'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await Geolocator.openAppSettings();
|
||||||
|
},
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (permission == LocationPermission.denied) {
|
||||||
|
// Request permission
|
||||||
|
final newPermission = await Geolocator.requestPermission();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (newPermission == LocationPermission.whileInUse ||
|
||||||
|
newPermission == LocationPermission.always) {
|
||||||
|
// Permission granted
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Location permission granted!'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {}); // Refresh UI to show new status
|
||||||
|
} else {
|
||||||
|
// Permission denied
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Location permission is required for GPS tracking and location sharing.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
duration: Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Already granted - show info
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Location permission is already granted.'),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error handling location permission: $e');
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Error: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _startBackgroundTracking() async {
|
Future<void> _startBackgroundTracking() async {
|
||||||
final success = await _locationService.startTracking(
|
final success = await _locationService.startTracking(
|
||||||
distanceThreshold: _locationService.gpsUpdateDistance,
|
distanceThreshold: _locationService.gpsUpdateDistance,
|
||||||
@@ -359,6 +442,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
|
// Permissions Section
|
||||||
|
_buildSectionHeader('Permissions'),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.location_on),
|
||||||
|
title: const Text('Location Permission'),
|
||||||
|
subtitle: FutureBuilder<LocationPermission>(
|
||||||
|
future: Geolocator.checkPermission(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Text('Checking...');
|
||||||
|
}
|
||||||
|
final permission = snapshot.data!;
|
||||||
|
String statusText;
|
||||||
|
Color statusColor;
|
||||||
|
|
||||||
|
switch (permission) {
|
||||||
|
case LocationPermission.always:
|
||||||
|
statusText = 'Granted (Always)';
|
||||||
|
statusColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case LocationPermission.whileInUse:
|
||||||
|
statusText = 'Granted (While In Use)';
|
||||||
|
statusColor = Colors.green;
|
||||||
|
break;
|
||||||
|
case LocationPermission.denied:
|
||||||
|
statusText = 'Denied - Tap to request';
|
||||||
|
statusColor = Colors.orange;
|
||||||
|
break;
|
||||||
|
case LocationPermission.deniedForever:
|
||||||
|
statusText = 'Permanently Denied - Open Settings';
|
||||||
|
statusColor = Colors.red;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusText = 'Unknown';
|
||||||
|
statusColor = Colors.grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
statusText,
|
||||||
|
style: TextStyle(color: statusColor),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _handleLocationPermissionTap(),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
|
||||||
// Location Settings Section
|
// Location Settings Section
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.locationBroadcasting),
|
_buildSectionHeader(AppLocalizations.of(context)!.locationBroadcasting),
|
||||||
|
|
||||||
|
|||||||
269
lib/services/ble/ble_command_queue.dart
Normal file
269
lib/services/ble/ble_command_queue.dart
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Type of response expected from a command
|
||||||
|
enum CommandResponseType {
|
||||||
|
/// No response expected (fire-and-forget)
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Wait for RESP_CODE_OK (0) or RESP_CODE_ERR (1)
|
||||||
|
ack,
|
||||||
|
|
||||||
|
/// Wait for specific response code with data
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a queued BLE command
|
||||||
|
class QueuedCommand<T> {
|
||||||
|
/// The command data to send
|
||||||
|
final Uint8List data;
|
||||||
|
|
||||||
|
/// Command code (first byte of data)
|
||||||
|
final int commandCode;
|
||||||
|
|
||||||
|
/// Type of response expected
|
||||||
|
final CommandResponseType responseType;
|
||||||
|
|
||||||
|
/// Expected response code (for data type commands)
|
||||||
|
final int? expectedResponseCode;
|
||||||
|
|
||||||
|
/// Completer to signal command completion
|
||||||
|
final Completer<T> completer;
|
||||||
|
|
||||||
|
/// Timeout duration for this command
|
||||||
|
final Duration timeout;
|
||||||
|
|
||||||
|
/// Timestamp when command was enqueued
|
||||||
|
final DateTime enqueuedAt;
|
||||||
|
|
||||||
|
QueuedCommand({
|
||||||
|
required this.data,
|
||||||
|
required this.commandCode,
|
||||||
|
required this.responseType,
|
||||||
|
this.expectedResponseCode,
|
||||||
|
required this.completer,
|
||||||
|
required this.timeout,
|
||||||
|
}) : enqueuedAt = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BLE command queue with mutex lock and inter-command delays
|
||||||
|
///
|
||||||
|
/// Ensures that:
|
||||||
|
/// - Only one command executes at a time
|
||||||
|
/// - 100ms delay between all commands
|
||||||
|
/// - Commands can wait for ACK or specific responses
|
||||||
|
/// - Timeouts are enforced
|
||||||
|
class BleCommandQueue {
|
||||||
|
// Queue of pending commands
|
||||||
|
final List<QueuedCommand> _queue = [];
|
||||||
|
|
||||||
|
// Mutex lock using Completer
|
||||||
|
Completer<void> _lock = Completer<void>()..complete();
|
||||||
|
|
||||||
|
// Whether queue is currently processing
|
||||||
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
// Pending responses mapped by command code
|
||||||
|
final Map<int, QueuedCommand> _pendingResponses = {};
|
||||||
|
|
||||||
|
// Last command execution timestamp
|
||||||
|
DateTime? _lastCommandTime;
|
||||||
|
|
||||||
|
// Minimum delay between commands (milliseconds)
|
||||||
|
static const int _minDelayMs = 100;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
VoidCallback? onQueueEmpty;
|
||||||
|
void Function(int queueSize)? onQueueSizeChanged;
|
||||||
|
|
||||||
|
/// Enqueue a command and wait for it to complete
|
||||||
|
///
|
||||||
|
/// [data] - The command data to send
|
||||||
|
/// [commandCode] - Command code (first byte)
|
||||||
|
/// [responseType] - Type of response expected
|
||||||
|
/// [expectedResponseCode] - For data responses, the expected response code
|
||||||
|
/// [timeout] - Maximum time to wait for response
|
||||||
|
///
|
||||||
|
/// Returns a Future that completes when the command receives its response
|
||||||
|
/// or throws TimeoutException if timeout expires.
|
||||||
|
Future<T> enqueue<T>({
|
||||||
|
required Uint8List data,
|
||||||
|
required int commandCode,
|
||||||
|
required CommandResponseType responseType,
|
||||||
|
int? expectedResponseCode,
|
||||||
|
Duration? timeout,
|
||||||
|
}) async {
|
||||||
|
// Determine timeout based on response type
|
||||||
|
final cmdTimeout = timeout ??
|
||||||
|
(responseType == CommandResponseType.data
|
||||||
|
? const Duration(seconds: 10)
|
||||||
|
: const Duration(seconds: 5));
|
||||||
|
|
||||||
|
// Create queued command
|
||||||
|
final command = QueuedCommand<T>(
|
||||||
|
data: data,
|
||||||
|
commandCode: commandCode,
|
||||||
|
responseType: responseType,
|
||||||
|
expectedResponseCode: expectedResponseCode,
|
||||||
|
completer: Completer<T>(),
|
||||||
|
timeout: cmdTimeout,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
_queue.add(command);
|
||||||
|
onQueueSizeChanged?.call(_queue.length);
|
||||||
|
|
||||||
|
debugPrint('📋 [CommandQueue] Enqueued command 0x${commandCode.toRadixString(16).padLeft(2, '0')} (queue size: ${_queue.length})');
|
||||||
|
|
||||||
|
// Start processing if not already running
|
||||||
|
if (!_isProcessing) {
|
||||||
|
_processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for command to complete or timeout
|
||||||
|
return command.completer.future.timeout(
|
||||||
|
cmdTimeout,
|
||||||
|
onTimeout: () {
|
||||||
|
debugPrint('⏱️ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out after ${cmdTimeout.inSeconds}s');
|
||||||
|
_pendingResponses.remove(commandCode);
|
||||||
|
throw TimeoutException('Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} timed out');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the command queue
|
||||||
|
Future<void> _processQueue() async {
|
||||||
|
if (_isProcessing) return;
|
||||||
|
_isProcessing = true;
|
||||||
|
|
||||||
|
while (_queue.isNotEmpty) {
|
||||||
|
// Wait for lock
|
||||||
|
await _lock.future;
|
||||||
|
|
||||||
|
// Get next command
|
||||||
|
final command = _queue.removeAt(0);
|
||||||
|
onQueueSizeChanged?.call(_queue.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Enforce minimum delay between commands
|
||||||
|
if (_lastCommandTime != null) {
|
||||||
|
final elapsed = DateTime.now().difference(_lastCommandTime!);
|
||||||
|
final remainingDelay = _minDelayMs - elapsed.inMilliseconds;
|
||||||
|
|
||||||
|
if (remainingDelay > 0) {
|
||||||
|
debugPrint('⏸️ [CommandQueue] Waiting ${remainingDelay}ms before next command');
|
||||||
|
await Future.delayed(Duration(milliseconds: remainingDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new lock for next command
|
||||||
|
_lock = Completer<void>();
|
||||||
|
|
||||||
|
// Register for response if needed
|
||||||
|
if (command.responseType != CommandResponseType.none) {
|
||||||
|
final responseKey = command.responseType == CommandResponseType.ack
|
||||||
|
? command.commandCode
|
||||||
|
: (command.expectedResponseCode ?? command.commandCode);
|
||||||
|
_pendingResponses[responseKey] = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute command (handled by BleCommandSender)
|
||||||
|
// The completer will be completed by completeCommand() when response arrives
|
||||||
|
debugPrint('📤 [CommandQueue] Executing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')}');
|
||||||
|
|
||||||
|
// For fire-and-forget commands, complete immediately
|
||||||
|
if (command.responseType == CommandResponseType.none) {
|
||||||
|
command.completer.complete(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last command time
|
||||||
|
_lastCommandTime = DateTime.now();
|
||||||
|
|
||||||
|
// Release lock after minimum delay
|
||||||
|
Future.delayed(const Duration(milliseconds: _minDelayMs), () {
|
||||||
|
if (!_lock.isCompleted) {
|
||||||
|
_lock.complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [CommandQueue] Error processing command: $e');
|
||||||
|
if (!command.completer.isCompleted) {
|
||||||
|
command.completer.completeError(e);
|
||||||
|
}
|
||||||
|
// Release lock on error
|
||||||
|
if (!_lock.isCompleted) {
|
||||||
|
_lock.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isProcessing = false;
|
||||||
|
onQueueEmpty?.call();
|
||||||
|
debugPrint('✅ [CommandQueue] Queue empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a pending command with response data
|
||||||
|
///
|
||||||
|
/// Called by BleResponseHandler when a response is received
|
||||||
|
void completeCommand<T>(int responseCode, T data) {
|
||||||
|
final command = _pendingResponses.remove(responseCode);
|
||||||
|
if (command != null) {
|
||||||
|
debugPrint('✅ [CommandQueue] Completing command 0x${command.commandCode.toRadixString(16).padLeft(2, '0')} with response 0x${responseCode.toRadixString(16).padLeft(2, '0')}');
|
||||||
|
if (!command.completer.isCompleted) {
|
||||||
|
command.completer.complete(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete a pending command with error
|
||||||
|
///
|
||||||
|
/// Called by BleResponseHandler when RESP_CODE_ERR is received
|
||||||
|
void completeCommandWithError(int commandCode, String error, {int? errorCode}) {
|
||||||
|
final command = _pendingResponses.remove(commandCode);
|
||||||
|
if (command != null) {
|
||||||
|
debugPrint('❌ [CommandQueue] Command 0x${commandCode.toRadixString(16).padLeft(2, '0')} failed: $error (code: $errorCode)');
|
||||||
|
if (!command.completer.isCompleted) {
|
||||||
|
command.completer.completeError(
|
||||||
|
Exception('Command failed: $error (error code: $errorCode)'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current queue size
|
||||||
|
int get queueSize => _queue.length;
|
||||||
|
|
||||||
|
/// Get number of pending responses
|
||||||
|
int get pendingResponseCount => _pendingResponses.length;
|
||||||
|
|
||||||
|
/// Check if queue is empty
|
||||||
|
bool get isEmpty => _queue.isEmpty;
|
||||||
|
|
||||||
|
/// Check if queue is processing
|
||||||
|
bool get isProcessing => _isProcessing;
|
||||||
|
|
||||||
|
/// Clear all pending commands (use with caution)
|
||||||
|
void clear() {
|
||||||
|
debugPrint('🗑️ [CommandQueue] Clearing queue (${_queue.length} commands, ${_pendingResponses.length} pending responses)');
|
||||||
|
|
||||||
|
// Complete all pending commands with error
|
||||||
|
for (final command in _pendingResponses.values) {
|
||||||
|
if (!command.completer.isCompleted) {
|
||||||
|
command.completer.completeError(Exception('Queue cleared'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.clear();
|
||||||
|
_pendingResponses.clear();
|
||||||
|
onQueueSizeChanged?.call(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose resources
|
||||||
|
void dispose() {
|
||||||
|
clear();
|
||||||
|
if (!_lock.isCompleted) {
|
||||||
|
_lock.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import '../meshcore_opcode_names.dart';
|
import '../meshcore_opcode_names.dart';
|
||||||
import '../../models/ble_packet_log.dart';
|
import '../../models/ble_packet_log.dart';
|
||||||
|
import 'ble_command_queue.dart';
|
||||||
|
|
||||||
/// Callback types for sender events
|
/// Callback types for sender events
|
||||||
typedef OnErrorCallback = void Function(String error);
|
typedef OnErrorCallback = void Function(String error);
|
||||||
@@ -14,6 +15,9 @@ class BleCommandSender {
|
|||||||
final List<BlePacketLog> _packetLogs = [];
|
final List<BlePacketLog> _packetLogs = [];
|
||||||
static const int _maxLogSize = 1000;
|
static const int _maxLogSize = 1000;
|
||||||
|
|
||||||
|
// Command queue for serialization and response waiting
|
||||||
|
final BleCommandQueue _commandQueue = BleCommandQueue();
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
OnErrorCallback? onError;
|
OnErrorCallback? onError;
|
||||||
VoidCallback? onTxActivity;
|
VoidCallback? onTxActivity;
|
||||||
@@ -21,17 +25,99 @@ class BleCommandSender {
|
|||||||
// Getters
|
// Getters
|
||||||
int get txPacketCount => _txPacketCount;
|
int get txPacketCount => _txPacketCount;
|
||||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||||
|
BleCommandQueue get commandQueue => _commandQueue;
|
||||||
|
|
||||||
/// Set the RX characteristic to write to
|
/// Set the RX characteristic to write to
|
||||||
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
void setRxCharacteristic(BluetoothCharacteristic? characteristic) {
|
||||||
_rxCharacteristic = characteristic;
|
_rxCharacteristic = characteristic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write data to RX characteristic
|
/// Write data to RX characteristic (fire-and-forget, no response expected)
|
||||||
|
///
|
||||||
|
/// This method is for commands that don't expect any response.
|
||||||
|
/// The command is queued and executed with proper spacing, but we don't wait
|
||||||
|
/// for any acknowledgment.
|
||||||
Future<void> writeData(Uint8List data) async {
|
Future<void> writeData(Uint8List data) async {
|
||||||
if (_rxCharacteristic == null) {
|
if (_rxCharacteristic == null) {
|
||||||
throw Exception('Not connected');
|
throw Exception('Not connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||||
|
|
||||||
|
// Enqueue the command (fire-and-forget)
|
||||||
|
await _commandQueue.enqueue<void>(
|
||||||
|
data: data,
|
||||||
|
commandCode: commandCode,
|
||||||
|
responseType: CommandResponseType.none,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actually send the data
|
||||||
|
await _sendToDevice(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write data and wait for ACK (RESP_CODE_OK or RESP_CODE_ERR)
|
||||||
|
///
|
||||||
|
/// This method should be used for setup commands that return RESP_CODE_OK (0)
|
||||||
|
/// on success or RESP_CODE_ERR (1) on failure.
|
||||||
|
///
|
||||||
|
/// Examples: setAdvertLatLon, setAdvertName, setRadioParams, etc.
|
||||||
|
Future<void> writeDataAndWaitForAck(Uint8List data) async {
|
||||||
|
if (_rxCharacteristic == null) {
|
||||||
|
throw Exception('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||||
|
|
||||||
|
// Enqueue the command (wait for ACK)
|
||||||
|
await _commandQueue.enqueue<void>(
|
||||||
|
data: data,
|
||||||
|
commandCode: commandCode,
|
||||||
|
responseType: CommandResponseType.ack,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actually send the data
|
||||||
|
await _sendToDevice(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write data and wait for specific response
|
||||||
|
///
|
||||||
|
/// This method should be used for query commands that return specific data.
|
||||||
|
///
|
||||||
|
/// Examples:
|
||||||
|
/// - CMD_DEVICE_QUERY → RESP_CODE_DEVICE_INFO
|
||||||
|
/// - CMD_APP_START → RESP_CODE_SELF_INFO
|
||||||
|
/// - CMD_GET_CONTACTS → RESP_CODE_CONTACTS_START
|
||||||
|
Future<T> writeDataAndWaitForResponse<T>(
|
||||||
|
Uint8List data,
|
||||||
|
int expectedResponseCode,
|
||||||
|
) async {
|
||||||
|
if (_rxCharacteristic == null) {
|
||||||
|
throw Exception('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
final commandCode = data.isNotEmpty ? data[0] : 0;
|
||||||
|
|
||||||
|
// Enqueue the command (wait for specific response)
|
||||||
|
final responseFuture = _commandQueue.enqueue<T>(
|
||||||
|
data: data,
|
||||||
|
commandCode: commandCode,
|
||||||
|
responseType: CommandResponseType.data,
|
||||||
|
expectedResponseCode: expectedResponseCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actually send the data
|
||||||
|
await _sendToDevice(data);
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
return responseFuture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal method to actually send data to the BLE device
|
||||||
|
Future<void> _sendToDevice(Uint8List data) async {
|
||||||
|
if (_rxCharacteristic == null) {
|
||||||
|
throw Exception('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract command code from first byte
|
// Extract command code from first byte
|
||||||
final commandCode = data.isNotEmpty ? data[0] : null;
|
final commandCode = data.isNotEmpty ? data[0] : null;
|
||||||
@@ -125,6 +211,7 @@ class BleCommandSender {
|
|||||||
|
|
||||||
/// Dispose resources
|
/// Dispose resources
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_commandQueue.dispose();
|
||||||
_rxCharacteristic = null;
|
_rxCharacteristic = null;
|
||||||
_packetLogs.clear();
|
_packetLogs.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../buffer_reader.dart';
|
|||||||
import '../meshcore_constants.dart';
|
import '../meshcore_constants.dart';
|
||||||
import '../meshcore_opcode_names.dart';
|
import '../meshcore_opcode_names.dart';
|
||||||
import '../protocol/frame_parser.dart';
|
import '../protocol/frame_parser.dart';
|
||||||
|
import 'ble_command_queue.dart';
|
||||||
|
|
||||||
/// Callback types for response events
|
/// Callback types for response events
|
||||||
typedef OnContactCallback = void Function(Contact contact);
|
typedef OnContactCallback = void Function(Contact contact);
|
||||||
@@ -31,6 +32,7 @@ typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int
|
|||||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||||
|
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||||
|
|
||||||
/// Processes incoming responses from the BLE device
|
/// Processes incoming responses from the BLE device
|
||||||
class BleResponseHandler {
|
class BleResponseHandler {
|
||||||
@@ -40,6 +42,9 @@ class BleResponseHandler {
|
|||||||
final List<BlePacketLog> _packetLogs = [];
|
final List<BlePacketLog> _packetLogs = [];
|
||||||
static const int _maxLogSize = 1000;
|
static const int _maxLogSize = 1000;
|
||||||
|
|
||||||
|
// Reference to command queue for completing pending commands
|
||||||
|
BleCommandQueue? _commandQueue;
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
OnContactCallback? onContactReceived;
|
OnContactCallback? onContactReceived;
|
||||||
OnContactsCompleteCallback? onContactsComplete;
|
OnContactsCompleteCallback? onContactsComplete;
|
||||||
@@ -60,6 +65,7 @@ class BleResponseHandler {
|
|||||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||||
OnErrorCallback? onError;
|
OnErrorCallback? onError;
|
||||||
OnContactNotFoundCallback? onContactNotFound;
|
OnContactNotFoundCallback? onContactNotFound;
|
||||||
|
OnChannelInfoCallback? onChannelInfoReceived;
|
||||||
VoidCallback? onRxActivity;
|
VoidCallback? onRxActivity;
|
||||||
|
|
||||||
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
|
||||||
@@ -69,6 +75,11 @@ class BleResponseHandler {
|
|||||||
int get rxPacketCount => _rxPacketCount;
|
int get rxPacketCount => _rxPacketCount;
|
||||||
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
|
||||||
|
|
||||||
|
/// Set the command queue for completing pending commands
|
||||||
|
void setCommandQueue(BleCommandQueue? queue) {
|
||||||
|
_commandQueue = queue;
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to TX characteristic notifications
|
/// Subscribe to TX characteristic notifications
|
||||||
void subscribeToNotifications(BluetoothCharacteristic txCharacteristic) {
|
void subscribeToNotifications(BluetoothCharacteristic txCharacteristic) {
|
||||||
_txSubscription = txCharacteristic.lastValueStream.listen(
|
_txSubscription = txCharacteristic.lastValueStream.listen(
|
||||||
@@ -195,12 +206,18 @@ class BleResponseHandler {
|
|||||||
print(' → Handling BatteryAndStorage');
|
print(' → Handling BatteryAndStorage');
|
||||||
_handleBatteryAndStorage(reader);
|
_handleBatteryAndStorage(reader);
|
||||||
break;
|
break;
|
||||||
|
case MeshCoreConstants.respChannelInfo:
|
||||||
|
print(' → Handling ChannelInfo');
|
||||||
|
_handleChannelInfo(reader);
|
||||||
|
break;
|
||||||
case MeshCoreConstants.respNoMoreMessages:
|
case MeshCoreConstants.respNoMoreMessages:
|
||||||
print(' → Response: No More Messages');
|
print(' → Response: No More Messages');
|
||||||
onNoMoreMessages?.call();
|
onNoMoreMessages?.call();
|
||||||
break;
|
break;
|
||||||
case MeshCoreConstants.respOk:
|
case MeshCoreConstants.respOk:
|
||||||
print(' → Response: OK');
|
print(' → Response: OK');
|
||||||
|
// Complete any pending ACK command
|
||||||
|
_commandQueue?.completeCommand<void>(MeshCoreConstants.respOk, null);
|
||||||
break;
|
break;
|
||||||
case MeshCoreConstants.respErr:
|
case MeshCoreConstants.respErr:
|
||||||
print(' → Response: ERROR');
|
print(' → Response: ERROR');
|
||||||
@@ -249,6 +266,13 @@ class BleResponseHandler {
|
|||||||
final result = FrameParser.parseSentConfirmation(reader);
|
final result = FrameParser.parseSentConfirmation(reader);
|
||||||
if (result.isNotEmpty) {
|
if (result.isNotEmpty) {
|
||||||
print(' ✅ [Sent] Message sent successfully');
|
print(' ✅ [Sent] Message sent successfully');
|
||||||
|
|
||||||
|
// Complete any pending command waiting for sent confirmation
|
||||||
|
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||||
|
MeshCoreConstants.respSent,
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
|
||||||
onMessageSent?.call(
|
onMessageSent?.call(
|
||||||
result['expectedAckTag'] as int,
|
result['expectedAckTag'] as int,
|
||||||
result['suggestedTimeout'] as int,
|
result['suggestedTimeout'] as int,
|
||||||
@@ -319,6 +343,13 @@ class BleResponseHandler {
|
|||||||
void _handleDeviceInfo(BufferReader reader) {
|
void _handleDeviceInfo(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
final info = FrameParser.parseDeviceInfo(reader);
|
final info = FrameParser.parseDeviceInfo(reader);
|
||||||
|
|
||||||
|
// Complete any pending command waiting for device info
|
||||||
|
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||||
|
MeshCoreConstants.respDeviceInfo,
|
||||||
|
info,
|
||||||
|
);
|
||||||
|
|
||||||
onDeviceInfoReceived?.call(info);
|
onDeviceInfoReceived?.call(info);
|
||||||
print(' ✅ [DeviceInfo] Parsed successfully');
|
print(' ✅ [DeviceInfo] Parsed successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -331,9 +362,16 @@ class BleResponseHandler {
|
|||||||
void _handleSelfInfo(BufferReader reader) {
|
void _handleSelfInfo(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
final info = FrameParser.parseSelfInfo(reader);
|
final info = FrameParser.parseSelfInfo(reader);
|
||||||
|
|
||||||
|
// Complete any pending command waiting for self info
|
||||||
if (info.isNotEmpty) {
|
if (info.isNotEmpty) {
|
||||||
|
_commandQueue?.completeCommand<Map<String, dynamic>>(
|
||||||
|
MeshCoreConstants.respSelfInfo,
|
||||||
|
info,
|
||||||
|
);
|
||||||
onSelfInfoReceived?.call(info);
|
onSelfInfoReceived?.call(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
print(' ✅ [SelfInfo] Parsed successfully');
|
print(' ✅ [SelfInfo] Parsed successfully');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(' ❌ [SelfInfo] Parsing error: $e');
|
print(' ❌ [SelfInfo] Parsing error: $e');
|
||||||
@@ -572,6 +610,23 @@ class BleResponseHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle ChannelInfo response
|
||||||
|
void _handleChannelInfo(BufferReader reader) {
|
||||||
|
try {
|
||||||
|
final info = FrameParser.parseChannelInfo(reader);
|
||||||
|
if (info.isNotEmpty) {
|
||||||
|
final channelIdx = info['channelIdx'] as int;
|
||||||
|
final channelName = info['channelName'] as String;
|
||||||
|
|
||||||
|
print(' ✅ [ChannelInfo] Channel $channelIdx: "${channelName}"');
|
||||||
|
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print(' ❌ [ChannelInfo] Parsing error: $e');
|
||||||
|
onError?.call('ChannelInfo parsing error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle Error response
|
/// Handle Error response
|
||||||
void _handleError(BufferReader reader) {
|
void _handleError(BufferReader reader) {
|
||||||
try {
|
try {
|
||||||
@@ -580,6 +635,13 @@ class BleResponseHandler {
|
|||||||
final errorMsg = FrameParser.getErrorMessage(errorCode);
|
final errorMsg = FrameParser.getErrorMessage(errorCode);
|
||||||
print(' ❌ [Error] $errorMsg');
|
print(' ❌ [Error] $errorMsg');
|
||||||
|
|
||||||
|
// Complete any pending ACK command with error
|
||||||
|
_commandQueue?.completeCommandWithError(
|
||||||
|
MeshCoreConstants.respOk, // Command was expecting OK, got ERR
|
||||||
|
errorMsg,
|
||||||
|
errorCode: errorCode,
|
||||||
|
);
|
||||||
|
|
||||||
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
|
||||||
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
|
||||||
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
|
||||||
|
|||||||
@@ -246,19 +246,20 @@ class LocationTrackingService {
|
|||||||
/// Returns true if successful, false otherwise.
|
/// Returns true if successful, false otherwise.
|
||||||
/// Note: This method returns immediately after starting the position stream.
|
/// Note: This method returns immediately after starting the position stream.
|
||||||
/// Initial position acquisition happens asynchronously in the background.
|
/// Initial position acquisition happens asynchronously in the background.
|
||||||
|
///
|
||||||
|
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||||
if (!_isInitialized || _bleService == null) {
|
if (!_isInitialized) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [LocationTracking] Service not initialized or BLE service null',
|
'⚠️ [LocationTracking] Service not initialized',
|
||||||
);
|
);
|
||||||
onError?.call('Location tracking service not initialized');
|
onError?.call('Location tracking service not initialized');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_bleService!.isConnected) {
|
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||||
debugPrint('⚠️ [LocationTracking] BLE not connected');
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
onError?.call('Not connected to mesh device');
|
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'ble/ble_connection_manager.dart';
|
|||||||
import 'ble/ble_command_sender.dart';
|
import 'ble/ble_command_sender.dart';
|
||||||
import 'ble/ble_response_handler.dart';
|
import 'ble/ble_response_handler.dart';
|
||||||
import 'protocol/frame_builder.dart';
|
import 'protocol/frame_builder.dart';
|
||||||
|
import 'meshcore_constants.dart';
|
||||||
|
|
||||||
/// Callback types for MeshCore events
|
/// Callback types for MeshCore events
|
||||||
typedef OnContactCallback = void Function(Contact contact);
|
typedef OnContactCallback = void Function(Contact contact);
|
||||||
@@ -30,6 +31,7 @@ typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int
|
|||||||
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
|
||||||
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
typedef OnErrorCallback = void Function(String error, {int? errorCode});
|
||||||
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
|
||||||
|
typedef OnChannelInfoCallback = void Function(int channelIdx, String channelName);
|
||||||
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
typedef OnConnectionStateCallback = void Function(bool isConnected);
|
||||||
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
|
||||||
typedef OnRssiUpdateCallback = void Function(int rssi);
|
typedef OnRssiUpdateCallback = void Function(int rssi);
|
||||||
@@ -64,6 +66,7 @@ class MeshCoreBleService {
|
|||||||
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
OnBatteryAndStorageCallback? onBatteryAndStorage;
|
||||||
OnErrorCallback? onError;
|
OnErrorCallback? onError;
|
||||||
OnContactNotFoundCallback? onContactNotFound;
|
OnContactNotFoundCallback? onContactNotFound;
|
||||||
|
OnChannelInfoCallback? onChannelInfoReceived;
|
||||||
|
|
||||||
// Activity callbacks (for blinking indicators)
|
// Activity callbacks (for blinking indicators)
|
||||||
VoidCallback? onRxActivity;
|
VoidCallback? onRxActivity;
|
||||||
@@ -157,6 +160,9 @@ class MeshCoreBleService {
|
|||||||
_responseHandler.onContactNotFound = (contactPublicKey) {
|
_responseHandler.onContactNotFound = (contactPublicKey) {
|
||||||
onContactNotFound?.call(contactPublicKey);
|
onContactNotFound?.call(contactPublicKey);
|
||||||
};
|
};
|
||||||
|
_responseHandler.onChannelInfoReceived = (channelIdx, channelName) {
|
||||||
|
onChannelInfoReceived?.call(channelIdx, channelName);
|
||||||
|
};
|
||||||
_responseHandler.onRxActivity = () {
|
_responseHandler.onRxActivity = () {
|
||||||
onRxActivity?.call();
|
onRxActivity?.call();
|
||||||
};
|
};
|
||||||
@@ -185,16 +191,30 @@ class MeshCoreBleService {
|
|||||||
Future<bool> connect(BluetoothDevice device) async {
|
Future<bool> connect(BluetoothDevice device) async {
|
||||||
final success = await _connectionManager.connect(device);
|
final success = await _connectionManager.connect(device);
|
||||||
if (success) {
|
if (success) {
|
||||||
// Setup command sender with RX characteristic
|
try {
|
||||||
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
|
// Setup command sender with RX characteristic
|
||||||
|
_commandSender.setRxCharacteristic(_connectionManager.rxCharacteristic);
|
||||||
|
|
||||||
// Setup response handler with TX characteristic
|
// Wire up command queue between sender and response handler
|
||||||
if (_connectionManager.txCharacteristic != null) {
|
_responseHandler.setCommandQueue(_commandSender.commandQueue);
|
||||||
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
|
|
||||||
|
// Setup response handler with TX characteristic
|
||||||
|
if (_connectionManager.txCharacteristic != null) {
|
||||||
|
_responseHandler.subscribeToNotifications(_connectionManager.txCharacteristic!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial device query and wait for responses
|
||||||
|
await _sendDeviceQuery();
|
||||||
|
|
||||||
|
print('✅ [Service] Device initialization complete');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
print('❌ [Service] Device initialization failed: $e');
|
||||||
|
// Disconnect on initialization failure
|
||||||
|
await disconnect();
|
||||||
|
onError?.call('Device initialization failed: $e');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial device query
|
|
||||||
await _sendDeviceQuery();
|
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
@@ -206,17 +226,30 @@ class MeshCoreBleService {
|
|||||||
|
|
||||||
/// Send initial device query and sync clock
|
/// Send initial device query and sync clock
|
||||||
Future<void> _sendDeviceQuery() async {
|
Future<void> _sendDeviceQuery() async {
|
||||||
// CRITICAL: Set device clock FIRST, before any other commands
|
// STEP 1: Send device query FIRST to get device capabilities
|
||||||
// This ensures the device has correct timestamps for all operations
|
// This is the first command to send per protocol documentation
|
||||||
print('⏰ [Service] Setting device clock before device query');
|
print('🔍 [Service] Querying device information (CMD_DEVICE_QUERY)...');
|
||||||
|
final deviceInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||||
|
FrameBuilder.buildDeviceQuery(),
|
||||||
|
MeshCoreConstants.respDeviceInfo,
|
||||||
|
);
|
||||||
|
print('✅ [Service] Device info received: firmware=${deviceInfo['firmwareVersion']}');
|
||||||
|
|
||||||
|
// STEP 2: Send app start to initialize the app session
|
||||||
|
// This is the first command after connection per protocol documentation
|
||||||
|
print('🚀 [Service] Sending app start (CMD_APP_START)...');
|
||||||
|
final selfInfo = await _commandSender.writeDataAndWaitForResponse<Map<String, dynamic>>(
|
||||||
|
FrameBuilder.buildAppStart(),
|
||||||
|
MeshCoreConstants.respSelfInfo,
|
||||||
|
);
|
||||||
|
print('✅ [Service] Self info received: node initialized');
|
||||||
|
|
||||||
|
// STEP 3: Set device clock AFTER initialization
|
||||||
|
// This ensures the device has correct timestamps for all subsequent operations
|
||||||
|
// Note: This command does not return an ACK, so we use writeData (fire-and-forget)
|
||||||
|
print('⏰ [Service] Setting device clock (CMD_SET_DEVICE_TIME)...');
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
|
await _commandSender.writeData(FrameBuilder.buildSetDeviceTime());
|
||||||
|
print('✅ [Service] Device clock sent (no ACK expected)');
|
||||||
// Small delay to ensure clock is set before proceeding
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
// Now send device query and app start
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildDeviceQuery());
|
|
||||||
await _commandSender.writeData(FrameBuilder.buildAppStart());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh device info (public method)
|
/// Refresh device info (public method)
|
||||||
@@ -333,7 +366,7 @@ class MeshCoreBleService {
|
|||||||
|
|
||||||
/// Set advertised name
|
/// Set advertised name
|
||||||
Future<void> setAdvertName(String name) async {
|
Future<void> setAdvertName(String name) async {
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetAdvertName(name));
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertName(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set advertised latitude and longitude
|
/// Set advertised latitude and longitude
|
||||||
@@ -341,7 +374,7 @@ class MeshCoreBleService {
|
|||||||
required double latitude,
|
required double latitude,
|
||||||
required double longitude,
|
required double longitude,
|
||||||
}) async {
|
}) async {
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetAdvertLatLon(
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon(
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
));
|
));
|
||||||
@@ -354,7 +387,7 @@ class MeshCoreBleService {
|
|||||||
required int spreadingFactor,
|
required int spreadingFactor,
|
||||||
required int codingRate,
|
required int codingRate,
|
||||||
}) async {
|
}) async {
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetRadioParams(
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetRadioParams(
|
||||||
frequency: frequency,
|
frequency: frequency,
|
||||||
bandwidth: bandwidth,
|
bandwidth: bandwidth,
|
||||||
spreadingFactor: spreadingFactor,
|
spreadingFactor: spreadingFactor,
|
||||||
@@ -364,7 +397,7 @@ class MeshCoreBleService {
|
|||||||
|
|
||||||
/// Set transmit power
|
/// Set transmit power
|
||||||
Future<void> setTxPower(int powerDbm) async {
|
Future<void> setTxPower(int powerDbm) async {
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetTxPower(powerDbm));
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetTxPower(powerDbm));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set other parameters
|
/// Set other parameters
|
||||||
@@ -374,7 +407,7 @@ class MeshCoreBleService {
|
|||||||
required int advertLocationPolicy,
|
required int advertLocationPolicy,
|
||||||
int multiAcks = 0,
|
int multiAcks = 0,
|
||||||
}) async {
|
}) async {
|
||||||
await _commandSender.writeData(FrameBuilder.buildSetOtherParams(
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetOtherParams(
|
||||||
manualAddContacts: manualAddContacts,
|
manualAddContacts: manualAddContacts,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: advertLocationPolicy,
|
advertLocationPolicy: advertLocationPolicy,
|
||||||
@@ -426,6 +459,41 @@ class MeshCoreBleService {
|
|||||||
print('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
print('✅ [BLE] CMD_REMOVE_CONTACT sent');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get information for a specific channel
|
||||||
|
Future<void> getChannel(int channelIdx) async {
|
||||||
|
await _commandSender.writeData(FrameBuilder.buildGetChannel(channelIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the name for a specific channel
|
||||||
|
Future<void> setChannel({
|
||||||
|
required int channelIdx,
|
||||||
|
required String channelName,
|
||||||
|
}) async {
|
||||||
|
print('📻 [BLE] Setting channel name:');
|
||||||
|
print(' Channel index: $channelIdx');
|
||||||
|
print(' Channel name: $channelName');
|
||||||
|
|
||||||
|
await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetChannel(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
channelName: channelName,
|
||||||
|
));
|
||||||
|
print('✅ [BLE] CMD_SET_CHANNEL sent');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync all channels from the device (typically 0-39)
|
||||||
|
/// This queries each channel to get its name and metadata
|
||||||
|
Future<void> syncAllChannels({int maxChannels = 40}) async {
|
||||||
|
print('📻 [Service] Syncing channels (0-${maxChannels - 1})...');
|
||||||
|
|
||||||
|
for (int i = 0; i < maxChannels; i++) {
|
||||||
|
await getChannel(i);
|
||||||
|
// Small delay to avoid overwhelming the device
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
print('✅ [Service] Channel sync complete');
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear packet logs
|
/// Clear packet logs
|
||||||
void clearPacketLogs() {
|
void clearPacketLogs() {
|
||||||
_commandSender.clearPacketLogs();
|
_commandSender.clearPacketLogs();
|
||||||
|
|||||||
@@ -243,4 +243,31 @@ class FrameBuilder {
|
|||||||
writer.writeBytes(contactPublicKey); // 32 bytes
|
writer.writeBytes(contactPublicKey); // 32 bytes
|
||||||
return writer.toBytes();
|
return writer.toBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build GetChannel command - retrieves information for a specific channel
|
||||||
|
static Uint8List buildGetChannel(int channelIdx) {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(MeshCoreConstants.cmdGetChannel); // 0x1F (31)
|
||||||
|
writer.writeByte(channelIdx); // 0-39 typically
|
||||||
|
return writer.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build SetChannel command - sets the name for a specific channel
|
||||||
|
static Uint8List buildSetChannel({
|
||||||
|
required int channelIdx,
|
||||||
|
required String channelName,
|
||||||
|
}) {
|
||||||
|
final writer = BufferWriter();
|
||||||
|
writer.writeByte(MeshCoreConstants.cmdSetChannel); // 0x20 (32)
|
||||||
|
writer.writeByte(channelIdx); // 0-39 typically
|
||||||
|
|
||||||
|
// Write channel name as null-terminated string in 32-byte field
|
||||||
|
final nameBytes = Uint8List(32);
|
||||||
|
final encoded = utf8.encode(channelName);
|
||||||
|
final copyLen = encoded.length > 31 ? 31 : encoded.length;
|
||||||
|
nameBytes.setRange(0, copyLen, encoded);
|
||||||
|
writer.writeBytes(nameBytes);
|
||||||
|
|
||||||
|
return writer.toBytes();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,6 +387,28 @@ class FrameParser {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse ChannelInfo response
|
||||||
|
static Map<String, dynamic> parseChannelInfo(BufferReader reader) {
|
||||||
|
if (reader.remainingBytesCount < 33) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = reader.readByte();
|
||||||
|
final channelName = reader.readCString(32);
|
||||||
|
|
||||||
|
// Additional fields if present in protocol
|
||||||
|
int? flags;
|
||||||
|
if (reader.hasRemaining) {
|
||||||
|
flags = reader.readByte();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'channelIdx': channelIdx,
|
||||||
|
'channelName': channelName,
|
||||||
|
'flags': flags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Get error message from error code
|
/// Get error message from error code
|
||||||
static String getErrorMessage(int errorCode) {
|
static String getErrorMessage(int errorCode) {
|
||||||
switch (errorCode) {
|
switch (errorCode) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
import '../../../providers/map_provider.dart';
|
||||||
|
|
||||||
/// Filter controls for the compass dialog.
|
/// Filter controls for the compass dialog.
|
||||||
/// Allows filtering of contacts and SAR marker types.
|
/// Allows filtering of contacts and SAR marker types.
|
||||||
@@ -38,6 +40,8 @@ class CompassFilters extends StatefulWidget {
|
|||||||
class _CompassFiltersState extends State<CompassFilters> {
|
class _CompassFiltersState extends State<CompassFilters> {
|
||||||
void _showFilterDialog() {
|
void _showFilterDialog() {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => StatefulBuilder(
|
builder: (context) => StatefulBuilder(
|
||||||
@@ -54,6 +58,20 @@ class _CompassFiltersState extends State<CompassFilters> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
// Trail visibility toggle
|
||||||
|
_CompactFilterItem(
|
||||||
|
icon: Icons.timeline,
|
||||||
|
color: Colors.blue,
|
||||||
|
label: 'Location Trail',
|
||||||
|
value: mapProvider.isTrailVisible,
|
||||||
|
onChanged: (value) {
|
||||||
|
mapProvider.toggleTrailVisibility();
|
||||||
|
setDialogState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Divider(height: 8),
|
||||||
|
const SizedBox(height: 4),
|
||||||
// Contacts filter
|
// Contacts filter
|
||||||
_CompactFilterItem(
|
_CompactFilterItem(
|
||||||
icon: Icons.person,
|
icon: Icons.person,
|
||||||
|
|||||||
155
lib/widgets/map/location_trail_layer.dart
Normal file
155
lib/widgets/map/location_trail_layer.dart
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/map_provider.dart';
|
||||||
|
|
||||||
|
/// Widget that renders the user's location trail on the map
|
||||||
|
class LocationTrailLayer extends StatelessWidget {
|
||||||
|
const LocationTrailLayer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) {
|
||||||
|
final trail = mapProvider.currentTrail;
|
||||||
|
final isVisible = mapProvider.isTrailVisible;
|
||||||
|
|
||||||
|
// Don't render if trail is hidden or empty
|
||||||
|
if (!isVisible || trail == null || trail.points.length < 2) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final points = trail.latLngPoints;
|
||||||
|
|
||||||
|
return PolylineLayer(
|
||||||
|
polylines: [
|
||||||
|
Polyline(
|
||||||
|
points: points,
|
||||||
|
strokeWidth: 4.0,
|
||||||
|
color: Colors.blue.withValues(alpha: 0.7),
|
||||||
|
borderStrokeWidth: 2.0,
|
||||||
|
borderColor: Colors.white.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget that shows trail statistics overlay
|
||||||
|
class TrailStatsOverlay extends StatelessWidget {
|
||||||
|
const TrailStatsOverlay({super.key});
|
||||||
|
|
||||||
|
String _formatDistance(double meters) {
|
||||||
|
if (meters < 1000) {
|
||||||
|
return '${meters.toStringAsFixed(0)} m';
|
||||||
|
} else {
|
||||||
|
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}m ${seconds}s';
|
||||||
|
} else {
|
||||||
|
return '${seconds}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) {
|
||||||
|
final trail = mapProvider.currentTrail;
|
||||||
|
final isVisible = mapProvider.isTrailVisible;
|
||||||
|
|
||||||
|
// Don't show if trail is hidden or doesn't exist
|
||||||
|
if (!isVisible || trail == null || trail.points.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final distance = mapProvider.totalTrailDistance;
|
||||||
|
final duration = mapProvider.trailDuration;
|
||||||
|
final pointCount = trail.points.length;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.timeline,
|
||||||
|
color: Colors.blue,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Location Trail',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatRow(Icons.straighten, _formatDistance(distance)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildStatRow(Icons.access_time, _formatDuration(duration)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildStatRow(Icons.place, '$pointCount points'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatRow(IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/widgets/map/trail_controls.dart
Normal file
214
lib/widgets/map/trail_controls.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/map_provider.dart';
|
||||||
|
|
||||||
|
/// Trail management controls widget
|
||||||
|
class TrailControls extends StatelessWidget {
|
||||||
|
const TrailControls({super.key});
|
||||||
|
|
||||||
|
void _showTrailMenu(BuildContext context) {
|
||||||
|
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timeline, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Location Trail',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Trail stats
|
||||||
|
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildStatRow(
|
||||||
|
icon: Icons.straighten,
|
||||||
|
label: 'Distance',
|
||||||
|
value: _formatDistance(mapProvider.totalTrailDistance),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatRow(
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: 'Duration',
|
||||||
|
value: _formatDuration(mapProvider.trailDuration),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatRow(
|
||||||
|
icon: Icons.place,
|
||||||
|
label: 'Points',
|
||||||
|
value: '${mapProvider.currentTrail!.points.length}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Clear trail button
|
||||||
|
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_showClearConfirmation(context, mapProvider);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
label: const Text('Clear Trail'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// No trail message
|
||||||
|
if (mapProvider.currentTrail == null || mapProvider.currentTrail!.points.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.timeline, size: 48, color: Colors.grey),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'No trail recorded yet',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Start location tracking to record your trail',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showClearConfirmation(BuildContext context, MapProvider mapProvider) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear Trail?'),
|
||||||
|
content: const Text(
|
||||||
|
'Are you sure you want to clear the current location trail? This action cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
mapProvider.clearCurrentTrail();
|
||||||
|
Navigator.pop(context); // Close dialog
|
||||||
|
Navigator.pop(context); // Close bottom sheet
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatRow({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Colors.blue),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDistance(double meters) {
|
||||||
|
if (meters < 1000) {
|
||||||
|
return '${meters.toStringAsFixed(0)} m';
|
||||||
|
} else {
|
||||||
|
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m ${seconds}s';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}m ${seconds}s';
|
||||||
|
} else {
|
||||||
|
return '${seconds}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FloatingActionButton.small(
|
||||||
|
heroTag: 'trail_controls',
|
||||||
|
tooltip: 'Trail Controls',
|
||||||
|
onPressed: () => _showTrailMenu(context),
|
||||||
|
child: const Icon(Icons.timeline),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
210
lib/widgets/permission_request_dialog.dart
Normal file
210
lib/widgets/permission_request_dialog.dart
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
/// Dialog that requests location permissions on app startup
|
||||||
|
class PermissionRequestDialog extends StatefulWidget {
|
||||||
|
final VoidCallback onPermissionsGranted;
|
||||||
|
final VoidCallback? onPermissionsDenied;
|
||||||
|
|
||||||
|
const PermissionRequestDialog({
|
||||||
|
super.key,
|
||||||
|
required this.onPermissionsGranted,
|
||||||
|
this.onPermissionsDenied,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PermissionRequestDialog> createState() => _PermissionRequestDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
|
||||||
|
bool _isRequesting = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Automatically check and request permissions when dialog opens
|
||||||
|
_checkAndRequestPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAndRequestPermissions() async {
|
||||||
|
if (_isRequesting) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if location service is enabled
|
||||||
|
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||||
|
if (!serviceEnabled) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Location services are disabled. Please enable location services in your device settings.';
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current permission
|
||||||
|
LocationPermission permission = await Geolocator.checkPermission();
|
||||||
|
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
// Request permission
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission == LocationPermission.denied) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Location permission denied. This app requires location access to track your position and share it with your team.';
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
widget.onPermissionsDenied?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission == LocationPermission.deniedForever) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Location permission permanently denied. Please enable location access in your device settings.';
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
widget.onPermissionsDenied?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission granted!
|
||||||
|
setState(() {
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dialog and notify parent
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onPermissionsGranted();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Error requesting permissions: $e';
|
||||||
|
_isRequesting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
// Prevent dismissing dialog by tapping outside
|
||||||
|
canPop: false,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Location Permission'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'MeshCore SAR needs access to your location to:',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildPermissionReason(
|
||||||
|
icon: Icons.track_changes,
|
||||||
|
text: 'Track your position during search and rescue operations',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildPermissionReason(
|
||||||
|
icon: Icons.share_location,
|
||||||
|
text: 'Share your location with team members via mesh network',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildPermissionReason(
|
||||||
|
icon: Icons.map,
|
||||||
|
text: 'Display your location and trail on the map',
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_isRequesting) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_errorMessage != null && !_isRequesting)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onPermissionsDenied?.call();
|
||||||
|
},
|
||||||
|
child: const Text('Skip'),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null && !_isRequesting)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
// Open app settings
|
||||||
|
await Geolocator.openLocationSettings();
|
||||||
|
},
|
||||||
|
child: const Text('Open Settings'),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null && !_isRequesting &&
|
||||||
|
!_errorMessage!.contains('permanently denied'))
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _checkAndRequestPermissions,
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPermissionReason({
|
||||||
|
required IconData icon,
|
||||||
|
required String text,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: Colors.grey),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user