mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
14 Commits
v2026.0307
...
v2026.0308
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
05153c9ceb | ||
|
|
cd508d5f74 | ||
|
|
937f91e496 | ||
|
|
1f826ae4c2 | ||
|
|
2e15ccb3f3 | ||
|
|
aa0777d4a3 | ||
|
|
e904d53d8b | ||
|
|
c4aef05dcf | ||
|
|
1c3af4f927 | ||
|
|
fe2e08c4e4 | ||
|
|
3433eae3a6 |
2
.github/workflows/build-artifacts.yml
vendored
2
.github/workflows/build-artifacts.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: "3.35.6"
|
FLUTTER_VERSION: "3.41.4"
|
||||||
APP_NAME: meshcore-sar
|
APP_NAME: meshcore-sar
|
||||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.35.6'
|
FLUTTER_VERSION: '3.41.4'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- nsd_ios (0.0.1):
|
- nsd_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- ObjectBox (4.4.1)
|
|
||||||
- objectbox_flutter_libs (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- ObjectBox (= 4.4.1)
|
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -103,7 +99,6 @@ DEPENDENCIES:
|
|||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
@@ -118,7 +113,6 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- ObjectBox
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
@@ -149,8 +143,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
nsd_ios:
|
nsd_ios:
|
||||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||||
objectbox_flutter_libs:
|
|
||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -182,12 +174,10 @@ SPEC CHECKSUMS:
|
|||||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
|
|||||||
@@ -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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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 = 100;
|
CURRENT_PROJECT_VERSION = 105;
|
||||||
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>100</string>
|
<string>105</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -5,17 +5,24 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="49.579697">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
|
||||||
|
|
||||||
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994">
|
||||||
|
|
||||||
|
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/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.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
|
||||||
[Application Loader Error Output]: [altool.104B12FA0] Validation failed (409) Invalid executable. The “Runner.app/Frameworks/objective_c.framework/objective_c” executable references an unsupported platform in the arm64 slice. Since your app targets the iOS platform, it should only include executables that support the iOS platform. Simulator platforms aren’t permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
|
[Application Loader Error Output]: [altool.104B12FA0] Validation failed (409) Invalid executable. The “Runner.app/Frameworks/objective_c.framework/objective_c” executable references an unsupported platform in the arm64 slice. Since your app targets the iOS platform, it should only include executables that support the iOS platform. Simulator platforms aren’t permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
|
||||||
" />
|
" />
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1570
lib/l10n/app_zh.arb
1570
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,6 @@ import 'providers/app_provider.dart';
|
|||||||
import 'providers/sensors_provider.dart';
|
import 'providers/sensors_provider.dart';
|
||||||
import 'services/voice_codec_service.dart';
|
import 'services/voice_codec_service.dart';
|
||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/tile_cache_service.dart';
|
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
@@ -248,18 +247,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Image provider (fragment reassembly + outgoing session cache)
|
// Image provider (fragment reassembly + outgoing session cache)
|
||||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||||
|
|
||||||
// Tile cache service
|
|
||||||
Provider(create: (_) => TileCacheService()),
|
|
||||||
|
|
||||||
// App provider that coordinates everything
|
// App provider that coordinates everything
|
||||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||||
ChangeNotifierProxyProvider6<
|
ChangeNotifierProxyProvider5<
|
||||||
ConnectionProvider,
|
ConnectionProvider,
|
||||||
ContactsProvider,
|
ContactsProvider,
|
||||||
MessagesProvider,
|
MessagesProvider,
|
||||||
DrawingProvider,
|
DrawingProvider,
|
||||||
ChannelsProvider,
|
ChannelsProvider,
|
||||||
TileCacheService,
|
|
||||||
AppProvider
|
AppProvider
|
||||||
>(
|
>(
|
||||||
create: (context) => AppProvider(
|
create: (context) => AppProvider(
|
||||||
@@ -270,7 +265,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: context.read<ChannelsProvider>(),
|
channelsProvider: context.read<ChannelsProvider>(),
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
tileCacheService: context.read<TileCacheService>(),
|
|
||||||
),
|
),
|
||||||
update:
|
update:
|
||||||
(
|
(
|
||||||
@@ -280,7 +274,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
messages,
|
messages,
|
||||||
drawings,
|
drawings,
|
||||||
channels,
|
channels,
|
||||||
tileCache,
|
|
||||||
previous,
|
previous,
|
||||||
) =>
|
) =>
|
||||||
previous ??
|
previous ??
|
||||||
@@ -292,7 +285,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: channels,
|
channelsProvider: channels,
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
tileCacheService: tileCache,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -231,10 +231,10 @@ extension ContactLocalization on Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String get routeSummary {
|
String get routeSummary {
|
||||||
if (routeIsUnknown || !routeHasPath) {
|
if (routeIsUnknown) {
|
||||||
return 'Flood/Unknown';
|
return 'Flood/Unknown';
|
||||||
}
|
}
|
||||||
if (routeHopCount == 0) {
|
if (!routeHasPath || routeHopCount == 0) {
|
||||||
return 'Direct';
|
return 'Direct';
|
||||||
}
|
}
|
||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -10,6 +9,7 @@ enum MapLayerType {
|
|||||||
googleHybrid,
|
googleHybrid,
|
||||||
googleRoadmap,
|
googleRoadmap,
|
||||||
googleTerrain,
|
googleTerrain,
|
||||||
|
// Kept for stored preference compatibility after MBTiles removal.
|
||||||
vectorMbtiles,
|
vectorMbtiles,
|
||||||
wmsBase,
|
wmsBase,
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,6 @@ class MapLayer {
|
|||||||
final String attribution;
|
final String attribution;
|
||||||
final double maxZoom;
|
final double maxZoom;
|
||||||
|
|
||||||
// Vector tile specific properties
|
|
||||||
final bool isVector;
|
|
||||||
final File? mbtilesFile;
|
|
||||||
final String? styleUrl;
|
|
||||||
final String? sourceName;
|
|
||||||
final bool? isGzipped;
|
|
||||||
|
|
||||||
// WMS specific properties
|
// WMS specific properties
|
||||||
final bool isWms;
|
final bool isWms;
|
||||||
final String? wmsBaseUrl;
|
final String? wmsBaseUrl;
|
||||||
@@ -43,11 +36,6 @@ class MapLayer {
|
|||||||
required this.urlTemplate,
|
required this.urlTemplate,
|
||||||
required this.attribution,
|
required this.attribution,
|
||||||
required this.maxZoom,
|
required this.maxZoom,
|
||||||
this.isVector = false,
|
|
||||||
this.mbtilesFile,
|
|
||||||
this.styleUrl,
|
|
||||||
this.sourceName,
|
|
||||||
this.isGzipped,
|
|
||||||
this.isWms = false,
|
this.isWms = false,
|
||||||
this.wmsBaseUrl,
|
this.wmsBaseUrl,
|
||||||
this.wmsLayers,
|
this.wmsLayers,
|
||||||
@@ -74,7 +62,7 @@ class MapLayer {
|
|||||||
case MapLayerType.googleTerrain:
|
case MapLayerType.googleTerrain:
|
||||||
return localizations.googleTerrain;
|
return localizations.googleTerrain;
|
||||||
case MapLayerType.vectorMbtiles:
|
case MapLayerType.vectorMbtiles:
|
||||||
// For vector tiles, use the name from metadata
|
// Legacy value kept only for preference migration compatibility.
|
||||||
return name;
|
return name;
|
||||||
case MapLayerType.wmsBase:
|
case MapLayerType.wmsBase:
|
||||||
// For WMS layers, use the name (will be localized separately)
|
// For WMS layers, use the name (will be localized separately)
|
||||||
@@ -182,28 +170,4 @@ class MapLayer {
|
|||||||
static MapLayer fromType(MapLayerType type) {
|
static MapLayer fromType(MapLayerType type) {
|
||||||
return allLayers.firstWhere((layer) => layer.type == type);
|
return allLayers.firstWhere((layer) => layer.type == type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a MapLayer from an MBTiles file
|
|
||||||
static MapLayer fromMbtilesFile({
|
|
||||||
required String name,
|
|
||||||
required File mbtilesFile,
|
|
||||||
required String styleUrl,
|
|
||||||
required String sourceName,
|
|
||||||
required double maxZoom,
|
|
||||||
required bool isGzipped,
|
|
||||||
String? attribution,
|
|
||||||
}) {
|
|
||||||
return MapLayer(
|
|
||||||
type: MapLayerType.vectorMbtiles,
|
|
||||||
name: name,
|
|
||||||
urlTemplate: '', // Not used for vector tiles
|
|
||||||
attribution: attribution ?? 'MBTiles',
|
|
||||||
maxZoom: maxZoom,
|
|
||||||
isVector: true,
|
|
||||||
mbtilesFile: mbtilesFile,
|
|
||||||
styleUrl: styleUrl,
|
|
||||||
sourceName: sourceName,
|
|
||||||
isGzipped: isGzipped,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'voice_provider.dart';
|
|||||||
import 'image_provider.dart' as ip;
|
import 'image_provider.dart' as ip;
|
||||||
import 'helpers/fragment_ack_wait_registry.dart';
|
import 'helpers/fragment_ack_wait_registry.dart';
|
||||||
import 'helpers/session_metadata_restore.dart';
|
import 'helpers/session_metadata_restore.dart';
|
||||||
import '../services/tile_cache_service.dart';
|
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/packet_capture_storage_service.dart';
|
import '../services/packet_capture_storage_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -24,6 +23,7 @@ import '../utils/voice_message_parser.dart';
|
|||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/media_swarm_protocol.dart';
|
import '../utils/media_swarm_protocol.dart';
|
||||||
import '../utils/message_airtime_estimator.dart';
|
import '../utils/message_airtime_estimator.dart';
|
||||||
|
import '../utils/fast_gps_packet.dart';
|
||||||
|
|
||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
@@ -35,7 +35,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
final ChannelsProvider channelsProvider;
|
final ChannelsProvider channelsProvider;
|
||||||
final VoiceProvider voiceProvider;
|
final VoiceProvider voiceProvider;
|
||||||
final ip.ImageProvider imageProvider;
|
final ip.ImageProvider imageProvider;
|
||||||
final TileCacheService tileCacheService;
|
|
||||||
final LocationTrackingService locationTrackingService =
|
final LocationTrackingService locationTrackingService =
|
||||||
LocationTrackingService();
|
LocationTrackingService();
|
||||||
final PacketCaptureStorageService packetCaptureStorageService =
|
final PacketCaptureStorageService packetCaptureStorageService =
|
||||||
@@ -44,8 +43,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
|
|
||||||
bool _isSimpleMode = true;
|
bool get isSimpleMode => true;
|
||||||
bool get isSimpleMode => _isSimpleMode;
|
|
||||||
|
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool get isMapEnabled => _isMapEnabled;
|
bool get isMapEnabled => _isMapEnabled;
|
||||||
@@ -79,6 +77,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
final Map<String, Future<bool>> _pendingMediaSwarmFetches = {};
|
||||||
final Map<String, Map<String, MediaSwarmAvailability>>
|
final Map<String, Map<String, MediaSwarmAvailability>>
|
||||||
_pendingMediaSwarmResponses = {};
|
_pendingMediaSwarmResponses = {};
|
||||||
|
bool _fastLocationScreenActive = false;
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
@@ -91,12 +90,9 @@ class AppProvider with ChangeNotifier {
|
|||||||
required this.channelsProvider,
|
required this.channelsProvider,
|
||||||
required this.voiceProvider,
|
required this.voiceProvider,
|
||||||
required this.imageProvider,
|
required this.imageProvider,
|
||||||
required this.tileCacheService,
|
|
||||||
}) {
|
}) {
|
||||||
_setupCallbacks();
|
_setupCallbacks();
|
||||||
_initializeTileCache();
|
|
||||||
_initializeLocationTracking();
|
_initializeLocationTracking();
|
||||||
_loadSimpleMode();
|
|
||||||
_loadMapEnabled();
|
_loadMapEnabled();
|
||||||
_loadContactsEnabled();
|
_loadContactsEnabled();
|
||||||
_loadSensorsEnabled();
|
_loadSensorsEnabled();
|
||||||
@@ -225,29 +221,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contact?.advName;
|
return contact?.advName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load simple mode setting from shared preferences
|
|
||||||
Future<void> _loadSimpleMode() async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
_isSimpleMode = prefs.getBool('simple_mode') ?? true;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error loading simple mode setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle simple mode on/off
|
|
||||||
Future<void> toggleSimpleMode(bool enabled) async {
|
|
||||||
try {
|
|
||||||
_isSimpleMode = enabled;
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool('simple_mode', enabled);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error saving simple mode setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load map enabled setting from shared preferences
|
/// Load map enabled setting from shared preferences
|
||||||
Future<void> _loadMapEnabled() async {
|
Future<void> _loadMapEnabled() async {
|
||||||
try {
|
try {
|
||||||
@@ -436,16 +409,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize tile cache service
|
|
||||||
Future<void> _initializeTileCache() async {
|
|
||||||
try {
|
|
||||||
await tileCacheService.initialize();
|
|
||||||
debugPrint('Tile cache initialized');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error initializing tile cache: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize location tracking service
|
/// Initialize location tracking service
|
||||||
Future<void> _initializeLocationTracking() async {
|
Future<void> _initializeLocationTracking() async {
|
||||||
try {
|
try {
|
||||||
@@ -473,6 +436,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
locationTrackingService.onFastLocationUpdate = (position, reason) {
|
||||||
|
unawaited(_sendFastLocationUpdate(position, reason: reason));
|
||||||
|
};
|
||||||
|
|
||||||
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
debugPrint('✅ [AppProvider] Location tracking service initialized');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [AppProvider] Error initializing location tracking: $e');
|
debugPrint('❌ [AppProvider] Error initializing location tracking: $e');
|
||||||
@@ -836,6 +803,18 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
|
// Magic 0x69 'i' = image fetch request; 0x56 'V' = voice packet.
|
||||||
// Magic 0x49 'I' = image packet.
|
// Magic 0x49 'I' = image packet.
|
||||||
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) {
|
||||||
|
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
|
||||||
|
if (fastGpsPacket != null) {
|
||||||
|
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
||||||
|
if (sender != null) {
|
||||||
|
contactsProvider.updateFastGps(
|
||||||
|
sender.publicKey.sublist(0, 6),
|
||||||
|
fastGpsPacket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
|
final rawProbeRequest = RawRouteProbeRequest.tryParseBinary(payload);
|
||||||
if (rawProbeRequest != null) {
|
if (rawProbeRequest != null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1210,10 +1189,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
// Sync channels to get channel names
|
// Sync channels to get channel names
|
||||||
// In simple mode: only sync first 5 channels for faster startup
|
// In simple mode: only sync first 5 channels for faster startup
|
||||||
// In normal mode: sync all channels (up to device max)
|
// In normal mode: sync all channels (up to device max)
|
||||||
final channelsToSync = _isSimpleMode ? 5 : null;
|
const channelsToSync = 5;
|
||||||
debugPrint(
|
debugPrint('📻 [AppProvider] Syncing channels (simple mode: max 5)...');
|
||||||
'📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...',
|
|
||||||
);
|
|
||||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||||
debugPrint('✅ [AppProvider] Channel sync complete');
|
debugPrint('✅ [AppProvider] Channel sync complete');
|
||||||
|
|
||||||
@@ -1382,6 +1359,46 @@ class AppProvider with ChangeNotifier {
|
|||||||
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setFastLocationUiActive(bool isActive) {
|
||||||
|
if (_fastLocationScreenActive == isActive) return;
|
||||||
|
_fastLocationScreenActive = isActive;
|
||||||
|
locationTrackingService.setFastLocationActiveUse(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendFastLocationUpdate(
|
||||||
|
dynamic position, {
|
||||||
|
required String reason,
|
||||||
|
}) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final publicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
if (publicKey == null || publicKey.length < 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final senderKey6 = publicKey
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final packet = FastGpsPacket(
|
||||||
|
senderKey6: senderKey6,
|
||||||
|
latitude: position.latitude as double,
|
||||||
|
longitude: position.longitude as double,
|
||||||
|
timestampSeconds: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'📍 [AppProvider] Sending fast GPS update ($reason): '
|
||||||
|
'${position.latitude}, ${position.longitude}',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
|
Contact? _resolveVoiceFetchRequester(VoiceFetchRequest request) {
|
||||||
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
||||||
if (liveContact != null) {
|
if (liveContact != null) {
|
||||||
@@ -2176,7 +2193,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
await connectionProvider.getContacts();
|
await connectionProvider.getContacts();
|
||||||
|
|
||||||
// Sync channels (respect simple mode settings)
|
// Sync channels (respect simple mode settings)
|
||||||
final channelsToSync = _isSimpleMode ? 5 : null;
|
const channelsToSync = 5;
|
||||||
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
await connectionProvider.syncChannels(maxChannels: channelsToSync);
|
||||||
|
|
||||||
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
// Messages are automatically synced via PUSH_CODE_MSG_WAITING events
|
||||||
@@ -2294,6 +2311,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
locationTrackingService.onBroadcastSent = null;
|
locationTrackingService.onBroadcastSent = null;
|
||||||
locationTrackingService.onError = null;
|
locationTrackingService.onError = null;
|
||||||
locationTrackingService.onTrackingStateChanged = null;
|
locationTrackingService.onTrackingStateChanged = null;
|
||||||
|
locationTrackingService.onFastLocationUpdate = null;
|
||||||
// Dispose the location tracking service to stop GPS stream and clean up resources
|
// Dispose the location tracking service to stop GPS stream and clean up resources
|
||||||
locationTrackingService.dispose();
|
locationTrackingService.dispose();
|
||||||
for (final timer in _voiceMissingRetryTimers.values) {
|
for (final timer in _voiceMissingRetryTimers.values) {
|
||||||
|
|||||||
@@ -1290,6 +1290,19 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a raw private zero-hop payload.
|
||||||
|
///
|
||||||
|
/// This wraps the raw custom transport using an empty path to match the
|
||||||
|
/// firmware's private multicast behavior.
|
||||||
|
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
|
||||||
|
if (!_activeService.isConnected) return;
|
||||||
|
await _activeService.sendRawVoicePacket(
|
||||||
|
contactPathLen: 0,
|
||||||
|
contactPath: Uint8List(0),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Request telemetry from contact
|
/// Request telemetry from contact
|
||||||
///
|
///
|
||||||
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
||||||
@@ -2015,6 +2028,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
await _activeService.resetPath(contactPublicKey);
|
await _activeService.resetPath(contactPublicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to reset path: $e';
|
_error = 'Failed to reset path: $e';
|
||||||
@@ -2034,6 +2048,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
final updatedContact = contact.copyWith(
|
final updatedContact = contact.copyWith(
|
||||||
outPathLen: signedEncodedPathLen,
|
outPathLen: signedEncodedPathLen,
|
||||||
outPath: Uint8List.fromList(paddedPathBytes),
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../models/contact.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
|
import '../utils/fast_gps_packet.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
|
||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
@@ -282,62 +283,16 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a new contact
|
// Check if this is a new contact
|
||||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
final isNewContact = existingContact == null;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||||||
);
|
);
|
||||||
|
|
||||||
Contact updatedContact;
|
final updatedContact = _mergeIncomingContact(
|
||||||
if (isNewContact) {
|
incomingContact: contact,
|
||||||
// New contact - add initial location to history if available
|
existingContact: existingContact,
|
||||||
updatedContact = contact.copyWith(isNew: true);
|
);
|
||||||
if (contact.advertLocation != null) {
|
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
contact.lastAdvert * 1000,
|
|
||||||
);
|
|
||||||
updatedContact = updatedContact.addAdvertLocation(
|
|
||||||
contact.advertLocation!,
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Existing contact - preserve history and isNew status
|
|
||||||
final existingContact = _contacts[contact.publicKeyHex]!;
|
|
||||||
final mergedTelemetry = _mergeTelemetryForContact(
|
|
||||||
existingTelemetry: existingContact.telemetry,
|
|
||||||
incomingTelemetry: contact.telemetry,
|
|
||||||
);
|
|
||||||
final incomingAdvertLocation = contact.advertLocation;
|
|
||||||
final existingAdvertLocation = existingContact.advertLocation;
|
|
||||||
|
|
||||||
// Start with existing contact
|
|
||||||
updatedContact = contact.copyWith(
|
|
||||||
isNew: existingContact.isNew,
|
|
||||||
advertHistory: existingContact.advertHistory,
|
|
||||||
telemetry: mergedTelemetry,
|
|
||||||
advLat: incomingAdvertLocation != null
|
|
||||||
? contact.advLat
|
|
||||||
: existingAdvertLocation != null
|
|
||||||
? existingContact.advLat
|
|
||||||
: contact.advLat,
|
|
||||||
advLon: incomingAdvertLocation != null
|
|
||||||
? contact.advLon
|
|
||||||
: existingAdvertLocation != null
|
|
||||||
? existingContact.advLon
|
|
||||||
: contact.advLon,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add new location to history if location has changed
|
|
||||||
if (contact.advertLocation != null) {
|
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
contact.lastAdvert * 1000,
|
|
||||||
);
|
|
||||||
updatedContact = updatedContact.addAdvertLocation(
|
|
||||||
contact.advertLocation!,
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
@@ -363,7 +318,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excluded++;
|
excluded++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_contacts[contact.publicKeyHex] = contact;
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
||||||
|
incomingContact: contact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
);
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
if (excluded > 0) {
|
if (excluded > 0) {
|
||||||
@@ -375,6 +334,60 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact _mergeIncomingContact({
|
||||||
|
required Contact incomingContact,
|
||||||
|
Contact? existingContact,
|
||||||
|
}) {
|
||||||
|
if (existingContact == null) {
|
||||||
|
var newContact = incomingContact.copyWith(isNew: true);
|
||||||
|
if (incomingContact.advertLocation != null) {
|
||||||
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
incomingContact.lastAdvert * 1000,
|
||||||
|
);
|
||||||
|
newContact = newContact.addAdvertLocation(
|
||||||
|
incomingContact.advertLocation!,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: existingContact.telemetry,
|
||||||
|
incomingTelemetry: incomingContact.telemetry,
|
||||||
|
);
|
||||||
|
final incomingAdvertLocation = incomingContact.advertLocation;
|
||||||
|
final existingAdvertLocation = existingContact.advertLocation;
|
||||||
|
|
||||||
|
var updatedContact = incomingContact.copyWith(
|
||||||
|
isNew: existingContact.isNew,
|
||||||
|
advertHistory: existingContact.advertHistory,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
|
advLat: incomingAdvertLocation != null
|
||||||
|
? incomingContact.advLat
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLat
|
||||||
|
: incomingContact.advLat,
|
||||||
|
advLon: incomingAdvertLocation != null
|
||||||
|
? incomingContact.advLon
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLon
|
||||||
|
: incomingContact.advLon,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (incomingAdvertLocation != null) {
|
||||||
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
incomingContact.lastAdvert * 1000,
|
||||||
|
);
|
||||||
|
updatedContact = updatedContact.addAdvertLocation(
|
||||||
|
incomingAdvertLocation,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedContact;
|
||||||
|
}
|
||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
@@ -464,6 +477,42 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) {
|
||||||
|
final contact = _findContactByPrefix(publicKeyPrefix);
|
||||||
|
if (contact == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: contact.telemetry,
|
||||||
|
incomingTelemetry: ContactTelemetry(
|
||||||
|
gpsLocation: LatLng(packet.latitude, packet.longitude),
|
||||||
|
batteryPercentage: null,
|
||||||
|
batteryMilliVolts: null,
|
||||||
|
temperature: null,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
packet.timestampSeconds * 1000,
|
||||||
|
),
|
||||||
|
humidity: null,
|
||||||
|
pressure: null,
|
||||||
|
extraSensorData: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedContact = contact.copyWith(
|
||||||
|
telemetry: updatedTelemetry,
|
||||||
|
lastAdvert: packet.timestampSeconds,
|
||||||
|
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
||||||
|
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
||||||
|
);
|
||||||
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool _isInvalidTelemetryGps(LatLng? location) {
|
bool _isInvalidTelemetryGps(LatLng? location) {
|
||||||
if (location == null) return false;
|
if (location == null) return false;
|
||||||
final lat = location.latitude;
|
final lat = location.latitude;
|
||||||
@@ -504,6 +553,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return existingTelemetry;
|
return existingTelemetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telemetry packets and contact refreshes are often sparse. Preserve the
|
||||||
|
// last known reading for any field that is omitted in the incoming update.
|
||||||
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||||||
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||||||
final mergedExtraSensorData = <String, dynamic>{
|
final mergedExtraSensorData = <String, dynamic>{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'contacts_provider.dart';
|
|||||||
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
||||||
|
|
||||||
class SensorsProvider with ChangeNotifier {
|
class SensorsProvider with ChangeNotifier {
|
||||||
|
static const Duration _successStateRetention = Duration(minutes: 1);
|
||||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
static const String _fieldSpanKey = 'sensor_field_spans';
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
@@ -26,6 +27,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final List<String> _watchedSensorKeys = <String>[];
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
final Map<String, SensorRefreshState> _refreshStates =
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
<String, SensorRefreshState>{};
|
<String, SensorRefreshState>{};
|
||||||
|
final Map<String, DateTime> _refreshStateUpdatedAt = <String, DateTime>{};
|
||||||
final Map<String, Set<String>> _visibleFieldsBySensor =
|
final Map<String, Set<String>> _visibleFieldsBySensor =
|
||||||
<String, Set<String>>{};
|
<String, Set<String>>{};
|
||||||
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
@@ -189,6 +191,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
Future<void> removeSensor(String publicKeyHex) async {
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
_watchedSensorKeys.remove(publicKeyHex);
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
_refreshStates.remove(publicKeyHex);
|
_refreshStates.remove(publicKeyHex);
|
||||||
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
_visibleFieldsBySensor.remove(publicKeyHex);
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
_fieldSpansBySensor.remove(publicKeyHex);
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
@@ -220,35 +223,78 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
Contact? contact;
|
await refreshSensor(
|
||||||
for (final entry in contactsProvider.contacts) {
|
publicKeyHex: key,
|
||||||
if (entry.publicKeyHex == key) {
|
contactsProvider: contactsProvider,
|
||||||
contact = entry;
|
connectionProvider: connectionProvider,
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (contact == null) {
|
|
||||||
_refreshStates[key] = SensorRefreshState.unavailable;
|
|
||||||
notifyListeners();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_refreshStates[key] = SensorRefreshState.refreshing;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
final result = await connectionProvider.smartPing(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
hasPath: contact.hasPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_refreshStates[key] = result.success
|
|
||||||
? SensorRefreshState.success
|
|
||||||
: SensorRefreshState.timeout;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isRefreshingAll = false;
|
_isRefreshingAll = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshSensor({
|
||||||
|
required String publicKeyHex,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) async {
|
||||||
|
Contact? contact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
contact = entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact == null) {
|
||||||
|
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
|
||||||
|
|
||||||
|
final result = await connectionProvider.smartPing(
|
||||||
|
contactPublicKey: contact.publicKey,
|
||||||
|
hasPath: contact.hasPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
_setRefreshState(
|
||||||
|
publicKeyHex,
|
||||||
|
result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearExpiredRefreshStates({DateTime? now}) {
|
||||||
|
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||||
|
final keysToClear = <String>[];
|
||||||
|
|
||||||
|
for (final entry in _refreshStates.entries) {
|
||||||
|
if (entry.value != SensorRefreshState.success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedAt = _refreshStateUpdatedAt[entry.key];
|
||||||
|
if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
|
||||||
|
keysToClear.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToClear.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in keysToClear) {
|
||||||
|
_refreshStates.remove(key);
|
||||||
|
_refreshStateUpdatedAt.remove(key);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
|
||||||
|
_refreshStates[publicKeyHex] = state;
|
||||||
|
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,26 +398,51 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final locationSet =
|
||||||
|
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
|
||||||
|
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
||||||
body: ListView(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.all(16),
|
child: ListView(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
// Device Info Card
|
children: [
|
||||||
Card(
|
_ConfigHeroCard(
|
||||||
child: Padding(
|
title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore',
|
||||||
padding: const EdgeInsets.all(16),
|
subtitle:
|
||||||
|
'${_getDeviceTypeString(context, deviceInfo.deviceType)} • ${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}',
|
||||||
|
chips: [
|
||||||
|
_StatusChipData(
|
||||||
|
icon: Icons.bluetooth,
|
||||||
|
label:
|
||||||
|
'${AppLocalizations.of(context)!.bleName}: ${deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown}',
|
||||||
|
),
|
||||||
|
_StatusChipData(
|
||||||
|
icon: Icons.my_location,
|
||||||
|
label: locationSet ? 'Location ready' : 'Location off',
|
||||||
|
emphasized: locationSet,
|
||||||
|
),
|
||||||
|
_StatusChipData(
|
||||||
|
icon: Icons.settings_input_antenna,
|
||||||
|
label: '${_freqController.text} MHz • $_selectedBandwidth',
|
||||||
|
),
|
||||||
|
_StatusChipData(
|
||||||
|
icon: Icons.key,
|
||||||
|
label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
|
title: AppLocalizations.of(context)!.deviceInformation,
|
||||||
|
subtitle:
|
||||||
|
'${deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown} • ${deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown}',
|
||||||
|
icon: Icons.memory_rounded,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.deviceInformation,
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
AppLocalizations.of(context)!.bleName,
|
AppLocalizations.of(context)!.bleName,
|
||||||
deviceInfo.deviceName ??
|
deviceInfo.deviceName ??
|
||||||
@@ -467,42 +492,75 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
const SizedBox(height: 24),
|
title: AppLocalizations.of(context)!.publicInfo,
|
||||||
|
subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh,
|
||||||
// Public Info Section
|
icon: Icons.public_rounded,
|
||||||
Card(
|
trailing: FilledButton.icon(
|
||||||
child: Padding(
|
onPressed: _savePublicInfo,
|
||||||
padding: const EdgeInsets.all(16),
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(AppLocalizations.of(context)!.save),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Container(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
padding: const EdgeInsets.all(14),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: colorScheme.surfaceContainerHighest.withValues(
|
||||||
AppLocalizations.of(context)!.publicInfo,
|
alpha: 0.45,
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.bold,
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_telemetryEnabled
|
||||||
|
? Icons.travel_explore
|
||||||
|
: Icons.location_disabled,
|
||||||
|
color: _telemetryEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
Row(
|
Expanded(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
IconButton.filled(
|
Text(
|
||||||
onPressed: _savePublicInfo,
|
AppLocalizations.of(
|
||||||
icon: const Icon(Icons.save),
|
context,
|
||||||
tooltip: AppLocalizations.of(context)!.save,
|
)!.telemetryAndLocationSharing,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_telemetryEnabled
|
||||||
|
? 'This device advertises position data to the mesh.'
|
||||||
|
: 'Position broadcasting is currently disabled.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
],
|
Switch(
|
||||||
|
value: _telemetryEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_telemetryEnabled = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// Mesh Network Name
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -513,62 +571,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
)!.nameBroadcastInMesh,
|
)!.nameBroadcastInMesh,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Telemetry Toggle - Compact version
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.telemetryAndLocationSharing,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: _telemetryEnabled,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_telemetryEnabled = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// GPS Coordinates (only show if telemetry enabled)
|
|
||||||
if (_telemetryEnabled) ...[
|
if (_telemetryEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: _CompactCoordinateField(
|
||||||
controller: _latController,
|
controller: _latController,
|
||||||
decoration: InputDecoration(
|
label: AppLocalizations.of(context)!.lat,
|
||||||
labelText: AppLocalizations.of(context)!.lat,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
signed: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: _CompactCoordinateField(
|
||||||
controller: _lonController,
|
controller: _lonController,
|
||||||
decoration: InputDecoration(
|
label: AppLocalizations.of(context)!.lon,
|
||||||
labelText: AppLocalizations.of(context)!.lon,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
signed: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -585,36 +602,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
const SizedBox(height: 24),
|
title: AppLocalizations.of(context)!.radioSettings,
|
||||||
|
subtitle:
|
||||||
// Radio Settings Section
|
'${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate',
|
||||||
Card(
|
icon: Icons.settings_input_antenna_rounded,
|
||||||
child: Padding(
|
trailing: FilledButton.icon(
|
||||||
padding: const EdgeInsets.all(16),
|
onPressed: _saveRadioSettings,
|
||||||
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(AppLocalizations.of(context)!.save),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
AppLocalizations.of(context)!.radioSettings,
|
child: _RadioMetricTile(
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
label: AppLocalizations.of(context)!.bandwidth,
|
||||||
fontWeight: FontWeight.bold,
|
value: _selectedBandwidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton.filled(
|
const SizedBox(width: 10),
|
||||||
onPressed: _saveRadioSettings,
|
Expanded(
|
||||||
icon: const Icon(Icons.save),
|
child: _RadioMetricTile(
|
||||||
tooltip: AppLocalizations.of(context)!.save,
|
label: AppLocalizations.of(context)!.spreadingFactor,
|
||||||
|
value: 'SF$_selectedSpreadingFactor',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _RadioMetricTile(
|
||||||
|
label: AppLocalizations.of(context)!.codingRate,
|
||||||
|
value: 'CR$_selectedCodingRate',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// LoRa Frequency
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _freqController,
|
controller: _freqController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -629,8 +655,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Bandwidth
|
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedBandwidth,
|
initialValue: _selectedBandwidth,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -652,8 +676,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Spreading Factor
|
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _selectedSpreadingFactor,
|
initialValue: _selectedSpreadingFactor,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -677,8 +699,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Coding Rate
|
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _selectedCodingRate,
|
initialValue: _selectedCodingRate,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -702,8 +722,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// TX Power
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _txPowerController,
|
controller: _txPowerController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -715,37 +733,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Repeat Mode (firmware v9+)
|
|
||||||
if (deviceInfo.clientRepeat != null) ...[
|
if (deviceInfo.clientRepeat != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
SwitchListTile(
|
Container(
|
||||||
contentPadding: EdgeInsets.zero,
|
padding: const EdgeInsets.all(14),
|
||||||
title: const Text('Client Repeat Mode'),
|
decoration: BoxDecoration(
|
||||||
subtitle:
|
color: colorScheme.surfaceContainerHighest.withValues(
|
||||||
deviceInfo.allowedRepeatFreqRanges != null &&
|
alpha: 0.45,
|
||||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
),
|
||||||
? Text(
|
borderRadius: BorderRadius.circular(18),
|
||||||
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
),
|
||||||
)
|
child: SwitchListTile(
|
||||||
: const Text(
|
contentPadding: EdgeInsets.zero,
|
||||||
'Repeat packets on behalf of nearby nodes',
|
title: const Text('Client Repeat Mode'),
|
||||||
),
|
subtitle:
|
||||||
value: _repeatEnabled,
|
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||||
onChanged: (value) {
|
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||||
setState(() {
|
? Text(
|
||||||
_repeatEnabled = value;
|
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
||||||
});
|
)
|
||||||
},
|
: const Text(
|
||||||
|
'Repeat packets on behalf of nearby nodes',
|
||||||
|
),
|
||||||
|
value: _repeatEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_repeatEnabled = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,6 +795,276 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ConfigHeroCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final List<_StatusChipData> chips;
|
||||||
|
|
||||||
|
const _ConfigHeroCard({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.chips,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
colorScheme.primaryContainer,
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.tune_rounded,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onPrimaryContainer.withValues(
|
||||||
|
alpha: 0.82,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: chips.map(_StatusChip.new).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConfigSectionCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final Widget child;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
const _ConfigSectionCard({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.child,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: colorScheme.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusChipData {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool emphasized;
|
||||||
|
|
||||||
|
const _StatusChipData({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.emphasized = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusChip extends StatelessWidget {
|
||||||
|
final _StatusChipData data;
|
||||||
|
|
||||||
|
const _StatusChip(this.data);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final chipColor = data.emphasized
|
||||||
|
? colorScheme.primary.withValues(alpha: 0.14)
|
||||||
|
: colorScheme.onPrimaryContainer.withValues(alpha: 0.10);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: chipColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(data.icon, size: 16, color: colorScheme.onPrimaryContainer),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactCoordinateField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _CompactCoordinateField({
|
||||||
|
required this.controller,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
signed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RadioMetricTile extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const _RadioMetricTile({required this.label, required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
class _InfoRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'messages_tab.dart';
|
|||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'map_management_screen.dart';
|
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
@@ -27,6 +26,8 @@ import '../utils/battery_display_helper.dart';
|
|||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
|
enum _AdvertMode { flood, direct }
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
final Function(Locale?) onLocaleChanged;
|
final Function(Locale?) onLocaleChanged;
|
||||||
@@ -47,7 +48,8 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
class _HomeScreenState extends State<HomeScreen>
|
||||||
|
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late final AppProvider _appProvider;
|
late final AppProvider _appProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
@@ -56,6 +58,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
|
||||||
List<_HomeTab> get _enabledTabs {
|
List<_HomeTab> get _enabledTabs {
|
||||||
return [
|
return [
|
||||||
@@ -77,6 +80,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_appProvider = context.read<AppProvider>();
|
_appProvider = context.read<AppProvider>();
|
||||||
_isMapEnabled = _appProvider.isMapEnabled;
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
@@ -182,6 +186,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTabActivated(_HomeTab tab) {
|
void _handleTabActivated(_HomeTab tab) {
|
||||||
|
_syncFastLocationUiState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -200,6 +205,20 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncFastLocationUiState() {
|
||||||
|
final isActiveTab =
|
||||||
|
_currentTab == _HomeTab.map || _currentTab == _HomeTab.messages;
|
||||||
|
_appProvider.setFastLocationUiActive(
|
||||||
|
_lifecycleState == AppLifecycleState.resumed && isActiveTab,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_lifecycleState = state;
|
||||||
|
_syncFastLocationUiState();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -211,6 +230,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_appProvider.setFastLocationUiActive(false);
|
||||||
_appProvider.removeListener(_handleAppProviderChanged);
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
@@ -245,7 +266,187 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _advertiseDevice(BuildContext context) async {
|
Future<void> _triggerAdvertFeedback() async {
|
||||||
|
final platform = Theme.of(context).platform;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (platform == TargetPlatform.iOS) {
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
} else {
|
||||||
|
if (await Vibration.hasVibrator()) {
|
||||||
|
await Vibration.vibrate(duration: 50);
|
||||||
|
} else {
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Haptic feedback error: $e');
|
||||||
|
await HapticFeedback.vibrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return showModalBottomSheet<_AdvertMode>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advert mode',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Choose how far this announcement should travel.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.hub_rounded,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(l10n.flood),
|
||||||
|
subtitle: const Text('Relay through repeaters across the mesh'),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.near_me_rounded,
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(l10n.direct),
|
||||||
|
subtitle: const Text('Nearby only, without repeater flooding'),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityBadge({
|
||||||
|
required String label,
|
||||||
|
required int count,
|
||||||
|
required bool isActive,
|
||||||
|
required Color activeColor,
|
||||||
|
}) {
|
||||||
|
final color = isActive ? activeColor : Colors.grey;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$label:$count',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactActivityIndicator({
|
||||||
|
required bool rxActive,
|
||||||
|
required bool txActive,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: rxActive ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: txActive ? Colors.blue : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _advertiseDevice(
|
||||||
|
BuildContext context, {
|
||||||
|
bool floodMode = true,
|
||||||
|
}) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
@@ -325,8 +526,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
// Small delay to ensure the lat/lon is set
|
// Small delay to ensure the lat/lon is set
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
// Send flood advertisement
|
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
|
||||||
await connectionProvider.sendSelfAdvert(floodMode: true);
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastLogger.success(
|
||||||
|
context,
|
||||||
|
floodMode ? 'Flood advert sent' : 'Direct advert sent',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Failed to advertise device: $e');
|
debugPrint('❌ Failed to advertise device: $e');
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -367,6 +574,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
appBar: shouldHideUI
|
appBar: shouldHideUI
|
||||||
? null
|
? null
|
||||||
: AppBar(
|
: AppBar(
|
||||||
|
toolbarHeight: 64,
|
||||||
|
titleSpacing: 8,
|
||||||
title: _buildCompactStatusBar(),
|
title: _buildCompactStatusBar(),
|
||||||
actions: [
|
actions: [
|
||||||
Consumer<ConnectionProvider>(
|
Consumer<ConnectionProvider>(
|
||||||
@@ -390,30 +599,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.map),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(AppLocalizations.of(context)!.mapManagement),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
// Capture context-dependent objects before async gap
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
Future.delayed(Duration.zero, () {
|
|
||||||
if (!mounted) return;
|
|
||||||
navigator.push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => MapManagementScreen(
|
|
||||||
tileCacheService: appProvider.tileCacheService,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -565,7 +750,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
final deviceInfo = provider.deviceInfo;
|
final deviceInfo = provider.deviceInfo;
|
||||||
final isConnected = deviceInfo.isConnected;
|
final isConnected = deviceInfo.isConnected;
|
||||||
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
final isBleConnected = isConnected && !isTcpConnected;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
// Disconnected state: show connect button
|
||||||
@@ -627,241 +811,244 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connected state: LEFT | CENTER | RIGHT layout
|
final theme = Theme.of(context);
|
||||||
return Row(
|
final subtitleColor = theme.colorScheme.onSurfaceVariant;
|
||||||
children: [
|
final signalColor = isTcpConnected
|
||||||
// LEFT: Name + BT/Battery + Cog
|
? Colors.green
|
||||||
Expanded(
|
: (deviceInfo.signalRssi != null
|
||||||
child: Row(
|
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
|
||||||
mainAxisSize: MainAxisSize.min,
|
: Colors.grey);
|
||||||
children: [
|
|
||||||
Flexible(
|
return LayoutBuilder(
|
||||||
child: Column(
|
builder: (context, constraints) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final isTight = constraints.maxWidth < 360;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
deviceInfo.selfName ??
|
fit: FlexFit.loose,
|
||||||
AppLocalizations.of(context)!.appTitle,
|
child: Column(
|
||||||
style: const TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontSize: 18,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
|
Text(
|
||||||
|
deviceInfo.selfName ??
|
||||||
|
AppLocalizations.of(context)!.appTitle,
|
||||||
|
style:
|
||||||
|
(isTight
|
||||||
|
? theme.textTheme.titleSmall
|
||||||
|
: theme.textTheme.titleMedium)
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isTcpConnected
|
||||||
|
? Icons.wifi_rounded
|
||||||
|
: Icons.bluetooth_connected_rounded,
|
||||||
|
size: 13,
|
||||||
|
color: signalColor,
|
||||||
|
),
|
||||||
|
if (!isTcpConnected &&
|
||||||
|
deviceInfo.signalRssi != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Text(
|
||||||
|
'${deviceInfo.signalRssi}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: signalColor,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (deviceInfo.batteryPercent != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
BatteryDisplayHelper.getBatteryIcon(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
size: 13,
|
||||||
|
color:
|
||||||
|
BatteryDisplayHelper.getBatteryColor(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 30,
|
||||||
|
child: Text(
|
||||||
|
'${deviceInfo.batteryPercent!.round()}%',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color:
|
||||||
|
BatteryDisplayHelper.getBatteryColor(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(width: 4),
|
||||||
mainAxisSize: MainAxisSize.min,
|
IconButton(
|
||||||
children: [
|
onPressed: () {
|
||||||
Icon(
|
Navigator.push(
|
||||||
isTcpConnected
|
context,
|
||||||
? Icons.wifi
|
MaterialPageRoute(
|
||||||
: Icons.bluetooth_connected,
|
builder: (context) =>
|
||||||
color: isTcpConnected
|
const DeviceConfigScreen(),
|
||||||
? Colors.green
|
),
|
||||||
: (deviceInfo.signalRssi != null
|
);
|
||||||
? BatteryDisplayHelper.getSignalColor(
|
},
|
||||||
deviceInfo.signalRssi!,
|
onLongPress: () {
|
||||||
)
|
Navigator.push(
|
||||||
: Colors.grey),
|
context,
|
||||||
size: 13,
|
MaterialPageRoute(
|
||||||
|
builder: (context) => PacketLogScreen(
|
||||||
|
bleService: provider.bleService,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
tooltip: AppLocalizations.of(context)!.settings,
|
||||||
|
icon: const Icon(Icons.tune_rounded),
|
||||||
|
color: subtitleColor,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
foregroundColor: subtitleColor,
|
||||||
|
minimumSize: Size.square(isTight ? 38 : 40),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
await _triggerAdvertFeedback();
|
||||||
|
if (!mounted || !context.mounted) return;
|
||||||
|
await _advertiseDevice(context);
|
||||||
|
},
|
||||||
|
onLongPress: () async {
|
||||||
|
await _triggerAdvertFeedback();
|
||||||
|
if (!mounted || !context.mounted) return;
|
||||||
|
|
||||||
|
final mode = await _showAdvertModeSheet(context);
|
||||||
|
if (!mounted || !context.mounted || mode == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _advertiseDevice(
|
||||||
|
context,
|
||||||
|
floodMode: mode == _AdvertMode.flood,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: isTight ? 38 : 40,
|
||||||
|
height: isTight ? 38 : 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary,
|
||||||
|
theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.78,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
if (isBleConnected &&
|
child: Icon(
|
||||||
deviceInfo.signalRssi != null) ...[
|
Icons.campaign_rounded,
|
||||||
const SizedBox(width: 3),
|
color: Colors.white,
|
||||||
Text(
|
size: isTight ? 18 : 20,
|
||||||
'${deviceInfo.signalRssi}',
|
),
|
||||||
style: TextStyle(
|
),
|
||||||
fontSize: 11,
|
|
||||||
color: BatteryDisplayHelper.getSignalColor(
|
|
||||||
deviceInfo.signalRssi!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (isTcpConnected) ...[
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'WiFi',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (deviceInfo.batteryPercent != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
BatteryDisplayHelper.getBatteryIcon(
|
|
||||||
deviceInfo.batteryPercent!,
|
|
||||||
),
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(
|
|
||||||
deviceInfo.batteryPercent!,
|
|
||||||
),
|
|
||||||
size: 13,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'${deviceInfo.batteryPercent!.round()}%',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(
|
|
||||||
deviceInfo.batteryPercent!,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Settings cog - hidden in simple mode
|
),
|
||||||
if (!context.watch<AppProvider>().isSimpleMode) ...[
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
const SizedBox(width: 8),
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const DeviceConfigScreen(),
|
builder: (context) =>
|
||||||
),
|
PacketLogScreen(bleService: provider.bleService),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
onLongPress: () {
|
},
|
||||||
Navigator.push(
|
child: isTight
|
||||||
context,
|
? _buildCompactActivityIndicator(
|
||||||
MaterialPageRoute(
|
rxActive: provider.rxActivity,
|
||||||
builder: (context) => PacketLogScreen(
|
txActive: provider.txActivity,
|
||||||
bleService: provider.bleService,
|
)
|
||||||
|
: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh
|
||||||
|
.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
_buildActivityBadge(
|
||||||
|
label: 'RX',
|
||||||
|
count: provider.rxPacketCount,
|
||||||
|
isActive: provider.rxActivity,
|
||||||
|
activeColor: Colors.green,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildActivityBadge(
|
||||||
|
label: 'TX',
|
||||||
|
count: provider.txPacketCount,
|
||||||
|
isActive: provider.txActivity,
|
||||||
|
activeColor: Colors.blue,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
},
|
else
|
||||||
child: Container(
|
SizedBox(width: isTight ? 24 : 74),
|
||||||
width: 32,
|
],
|
||||||
height: 32,
|
);
|
||||||
alignment: Alignment.center,
|
},
|
||||||
child: const Icon(Icons.settings, size: 18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// CENTER: Broadcast button
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () async {
|
|
||||||
// Capture platform before async operations
|
|
||||||
final platform = Theme.of(context).platform;
|
|
||||||
// iOS: Use haptic feedback (always works)
|
|
||||||
// Android: Try vibration package for better control
|
|
||||||
try {
|
|
||||||
if (platform == TargetPlatform.iOS) {
|
|
||||||
// iOS: Try multiple haptic types for reliability
|
|
||||||
await HapticFeedback.lightImpact();
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
await HapticFeedback.lightImpact();
|
|
||||||
} else {
|
|
||||||
// Android vibration
|
|
||||||
if (await Vibration.hasVibrator()) {
|
|
||||||
await Vibration.vibrate(duration: 50);
|
|
||||||
} else {
|
|
||||||
await HapticFeedback.mediumImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback if anything fails
|
|
||||||
debugPrint('Haptic feedback error: $e');
|
|
||||||
await HapticFeedback.vibrate();
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
_advertiseDevice(context);
|
|
||||||
},
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.blue.shade700,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
minimumSize: const Size(40, 40),
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.campaign, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
|
|
||||||
// RIGHT: RX/TX indicators
|
|
||||||
if (_showRxTxIndicators)
|
|
||||||
GestureDetector(
|
|
||||||
onLongPress: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
PacketLogScreen(bleService: provider.bleService),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: provider.rxActivity
|
|
||||||
? Colors.green
|
|
||||||
: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'RX:${provider.rxPacketCount}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: provider.txActivity
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'TX:${provider.txPacketCount}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(
|
|
||||||
width: 52,
|
|
||||||
), // Placeholder to maintain layout balance
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1436,10 +1436,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
|
|
||||||
// Get simple mode setting from AppProvider
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
|
|
||||||
List<Message> filteredMessages;
|
List<Message> filteredMessages;
|
||||||
|
|
||||||
// If channel destination is selected, filter by selected channel.
|
// If channel destination is selected, filter by selected channel.
|
||||||
@@ -1492,14 +1488,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
filteredMessages = allMessages;
|
filteredMessages = allMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In simple mode, filter out system messages (toast logs)
|
return filteredMessages
|
||||||
if (isSimpleMode) {
|
.where((message) => !message.isSystemMessage)
|
||||||
filteredMessages = filteredMessages
|
.toList();
|
||||||
.where((message) => !message.isSystemMessage)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredMessages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleMessageTap(Message message) {
|
void _handleMessageTap(Message message) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -10,9 +12,53 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../providers/sensors_provider.dart';
|
import '../providers/sensors_provider.dart';
|
||||||
import '../utils/location_formats.dart';
|
import '../utils/location_formats.dart';
|
||||||
|
|
||||||
class SensorsTab extends StatelessWidget {
|
class SensorsTab extends StatefulWidget {
|
||||||
const SensorsTab({super.key});
|
const SensorsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SensorsTab> createState() => _SensorsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SensorsTabState extends State<SensorsTab> {
|
||||||
|
Timer? _minuteTicker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scheduleMinuteTicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_minuteTicker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleMinuteTicker() {
|
||||||
|
_minuteTicker?.cancel();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final nextMinute = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
now.hour,
|
||||||
|
now.minute + 1,
|
||||||
|
);
|
||||||
|
final delay = nextMinute.difference(now);
|
||||||
|
|
||||||
|
_minuteTicker = Timer(delay, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<SensorsProvider>().clearExpiredRefreshStates();
|
||||||
|
setState(() {});
|
||||||
|
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<SensorsProvider>().clearExpiredRefreshStates();
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showAddSensorSheet(BuildContext context) async {
|
Future<void> _showAddSensorSheet(BuildContext context) async {
|
||||||
final sensorsProvider = context.read<SensorsProvider>();
|
final sensorsProvider = context.read<SensorsProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -209,6 +255,11 @@ class SensorsTab extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
onCustomize: () =>
|
onCustomize: () =>
|
||||||
_showMetricSelector(context, key, contact),
|
_showMetricSelector(context, key, contact),
|
||||||
|
onRefresh: () => sensorsProvider.refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: context.read<ConnectionProvider>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -302,6 +353,7 @@ class _SensorCard extends StatelessWidget {
|
|||||||
final Set<String> visibleFields;
|
final Set<String> visibleFields;
|
||||||
final Map<String, int> fieldSpans;
|
final Map<String, int> fieldSpans;
|
||||||
final Future<void> Function() onRemove;
|
final Future<void> Function() onRemove;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
final VoidCallback onCustomize;
|
final VoidCallback onCustomize;
|
||||||
|
|
||||||
const _SensorCard({
|
const _SensorCard({
|
||||||
@@ -310,6 +362,7 @@ class _SensorCard extends StatelessWidget {
|
|||||||
required this.visibleFields,
|
required this.visibleFields,
|
||||||
required this.fieldSpans,
|
required this.fieldSpans,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
|
required this.onRefresh,
|
||||||
required this.onCustomize,
|
required this.onCustomize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,18 +457,24 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'remove') {
|
if (value == 'refresh') {
|
||||||
|
await onRefresh();
|
||||||
|
} else if (value == 'remove') {
|
||||||
await onRemove();
|
await onRemove();
|
||||||
} else if (value == 'customize') {
|
} else if (value == 'customize') {
|
||||||
onCustomize();
|
onCustomize();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
|
value: 'refresh',
|
||||||
|
child: Text(l10n.refresh),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
value: 'customize',
|
value: 'customize',
|
||||||
child: Text('Customize fields'),
|
child: Text('Customize fields'),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
const PopupMenuItem<String>(
|
||||||
value: 'remove',
|
value: 'remove',
|
||||||
child: Text('Remove'),
|
child: Text('Remove'),
|
||||||
),
|
),
|
||||||
@@ -429,7 +488,9 @@ class _SensorCard extends StatelessWidget {
|
|||||||
'This node is no longer available in the contact list.',
|
'This node is no longer available in the contact list.',
|
||||||
)
|
)
|
||||||
else if (telemetry == null)
|
else if (telemetry == null)
|
||||||
const Text('No telemetry received yet. Pull down to fetch it.')
|
const Text(
|
||||||
|
'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.',
|
||||||
|
)
|
||||||
else if (metrics.isEmpty)
|
else if (metrics.isEmpty)
|
||||||
const Text(
|
const Text(
|
||||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||||
@@ -573,7 +634,7 @@ class _SensorCard extends StatelessWidget {
|
|||||||
|
|
||||||
String _formatTelemetryTime(DateTime timestamp) {
|
String _formatTelemetryTime(DateTime timestamp) {
|
||||||
final diff = DateTime.now().difference(timestamp);
|
final diff = DateTime.now().difference(timestamp);
|
||||||
if (diff.inMinutes < 1) return 'just now';
|
if (diff.inMinutes < 1) return 'now';
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
return '${diff.inDays}d ago';
|
return '${diff.inDays}d ago';
|
||||||
@@ -673,49 +734,33 @@ class _MetricTile extends StatelessWidget {
|
|||||||
final location = data.mapLocation;
|
final location = data.mapLocation;
|
||||||
if (location == null) return;
|
if (location == null) return;
|
||||||
|
|
||||||
await showDialog<void>(
|
await Navigator.of(context).push(
|
||||||
context: context,
|
MaterialPageRoute<void>(
|
||||||
builder: (dialogContext) {
|
builder: (pageContext) {
|
||||||
return Dialog(
|
return Scaffold(
|
||||||
insetPadding: const EdgeInsets.all(16),
|
appBar: AppBar(
|
||||||
clipBehavior: Clip.antiAlias,
|
title: Column(
|
||||||
child: SizedBox(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
height: 420,
|
children: [
|
||||||
child: Column(
|
Text(data.label),
|
||||||
|
Text(
|
||||||
|
data.value,
|
||||||
|
style: Theme.of(pageContext).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
if (data.secondaryValue != null)
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
|
Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
children: [
|
child: Text(
|
||||||
Expanded(
|
data.secondaryValue!,
|
||||||
child: Column(
|
style: Theme.of(pageContext).textTheme.bodyMedium,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
data.label,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
data.value,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
if (data.secondaryValue != null)
|
|
||||||
Text(
|
|
||||||
data.secondaryValue!,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: flutter_map.FlutterMap(
|
child: flutter_map.FlutterMap(
|
||||||
options: flutter_map.MapOptions(
|
options: flutter_map.MapOptions(
|
||||||
@@ -748,9 +793,10 @@ class _MetricTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Uint8List? _previewCompressedBytes;
|
Uint8List? _previewCompressedBytes;
|
||||||
bool _isPreviewLoading = false;
|
bool _isPreviewLoading = false;
|
||||||
bool _showCurrentImagePreview = true;
|
bool _showCurrentImagePreview = true;
|
||||||
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadVoiceBitratePreference();
|
_loadVoiceBitratePreference();
|
||||||
_loadRouteHashSizePreference();
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
|
_loadFastLocationSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -169,6 +173,108 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _refreshImageModePreview();
|
await _refreshImageModePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFastLocationSettings() async {
|
||||||
|
await _locationService.loadSettings();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
||||||
|
_fastLocationMovementThresholdMeters =
|
||||||
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
|
_fastLocationActiveCadenceSeconds =
|
||||||
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
|
await _locationService.setFastLocationUpdatesEnabled(enabled);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editFastLocationMovementThreshold() async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: _fastLocationMovementThresholdMeters.toStringAsFixed(0),
|
||||||
|
);
|
||||||
|
final value = await showDialog<double>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Fast GPS movement threshold'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Meters',
|
||||||
|
helperText: 'Valid range: 1 to 1000 meters',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final parsed = double.tryParse(controller.text.trim());
|
||||||
|
if (parsed == null) return;
|
||||||
|
Navigator.pop(context, parsed.clamp(1.0, 1000.0));
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value == null) return;
|
||||||
|
await _locationService.updateFastLocationMovementThreshold(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationMovementThresholdMeters =
|
||||||
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editFastLocationActiveCadence() async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: _fastLocationActiveCadenceSeconds.toString(),
|
||||||
|
);
|
||||||
|
final value = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Fast GPS active-use interval'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Seconds',
|
||||||
|
helperText: 'Valid range: 5 to 60 seconds',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final parsed = int.tryParse(controller.text.trim());
|
||||||
|
if (parsed == null) return;
|
||||||
|
Navigator.pop(context, parsed.clamp(5, 60));
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value == null) return;
|
||||||
|
await _locationService.updateFastLocationActiveCadenceSeconds(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationActiveCadenceSeconds =
|
||||||
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveImageMaxSize(int size) async {
|
Future<void> _saveImageMaxSize(int size) async {
|
||||||
await ImagePreferences.setMaxSize(size);
|
await ImagePreferences.setMaxSize(size);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -758,19 +864,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
_buildSectionHeader('Navigation'),
|
_buildSectionHeader('Navigation'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.visibility_off),
|
|
||||||
title: Text(AppLocalizations.of(context)!.simpleMode),
|
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(context)!.simpleModeDescription,
|
|
||||||
),
|
|
||||||
value: appProvider.isSimpleMode,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleSimpleMode(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.map_outlined),
|
secondary: const Icon(Icons.map_outlined),
|
||||||
@@ -1018,6 +1111,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.gps_fixed),
|
||||||
|
title: const Text('Fast private GPS updates'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Use private zero-hop updates while moving significantly or while actively using map/messages.',
|
||||||
|
),
|
||||||
|
value: _fastLocationUpdatesEnabled,
|
||||||
|
onChanged: _setFastLocationUpdatesEnabled,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.straighten),
|
||||||
|
title: const Text('Movement threshold'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _editFastLocationMovementThreshold,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.timer),
|
||||||
|
title: const Text('Active-use update interval'),
|
||||||
|
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _editFastLocationActiveCadence,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_on),
|
leading: const Icon(Icons.location_on),
|
||||||
title: Text(AppLocalizations.of(context)!.locationPermission),
|
title: Text(AppLocalizations.of(context)!.locationPermission),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class WelcomeWizardScreen extends StatefulWidget {
|
|||||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
static const int _totalPages = 6;
|
static const int _totalPages = 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -99,7 +99,6 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildWelcomePage(context, l10n, colorScheme),
|
_buildWelcomePage(context, l10n, colorScheme),
|
||||||
_buildConnectingPage(context, l10n, colorScheme),
|
_buildConnectingPage(context, l10n, colorScheme),
|
||||||
_buildSimpleModePage(context, l10n, colorScheme),
|
|
||||||
_buildChannelPage(context, l10n, colorScheme),
|
_buildChannelPage(context, l10n, colorScheme),
|
||||||
_buildContactsPage(context, l10n, colorScheme),
|
_buildContactsPage(context, l10n, colorScheme),
|
||||||
_buildMapPage(context, l10n, colorScheme),
|
_buildMapPage(context, l10n, colorScheme),
|
||||||
@@ -182,42 +181,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardConnectingTitle,
|
title: l10n.wizardConnectingTitle,
|
||||||
description: l10n.wizardConnectingDescription,
|
description: l10n.wizardConnectingDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
|
||||||
icon: Icons.radio,
|
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
|
||||||
text: l10n.wizardConnectingFeature1,
|
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.link,
|
|
||||||
text: l10n.wizardConnectingFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.wifi_off,
|
|
||||||
text: l10n.wizardConnectingFeature3,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSimpleModePage(
|
|
||||||
BuildContext context,
|
|
||||||
AppLocalizations l10n,
|
|
||||||
ColorScheme colorScheme,
|
|
||||||
) {
|
|
||||||
return _buildPage(
|
|
||||||
icon: Icons.toggle_on,
|
|
||||||
iconColor: Colors.green,
|
|
||||||
title: l10n.wizardSimpleModeTitle,
|
|
||||||
description: l10n.wizardSimpleModeDescription,
|
|
||||||
features: [
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.check_circle_outline,
|
|
||||||
text: l10n.wizardSimpleModeFeature1,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.settings,
|
|
||||||
text: l10n.wizardSimpleModeFeature2,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -234,18 +200,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardChannelTitle,
|
title: l10n.wizardChannelTitle,
|
||||||
description: l10n.wizardChannelDescription,
|
description: l10n.wizardChannelDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
|
||||||
icon: Icons.public,
|
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
|
||||||
text: l10n.wizardChannelFeature1,
|
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.groups,
|
|
||||||
text: l10n.wizardChannelFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.send,
|
|
||||||
text: l10n.wizardChannelFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -262,14 +219,8 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardContactsTitle,
|
title: l10n.wizardContactsTitle,
|
||||||
description: l10n.wizardContactsDescription,
|
description: l10n.wizardContactsDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
|
||||||
icon: Icons.person_add,
|
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
|
||||||
text: l10n.wizardContactsFeature1,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.chat,
|
|
||||||
text: l10n.wizardContactsFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.battery_std,
|
icon: Icons.battery_std,
|
||||||
text: l10n.wizardContactsFeature3,
|
text: l10n.wizardContactsFeature3,
|
||||||
@@ -290,22 +241,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardMapTitle,
|
title: l10n.wizardMapTitle,
|
||||||
description: l10n.wizardMapDescription,
|
description: l10n.wizardMapDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
|
||||||
icon: Icons.location_on,
|
|
||||||
text: l10n.wizardMapFeature1,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.person_pin_circle,
|
icon: Icons.person_pin_circle,
|
||||||
text: l10n.wizardMapFeature2,
|
text: l10n.wizardMapFeature2,
|
||||||
),
|
),
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
|
||||||
icon: Icons.offline_pin,
|
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
|
||||||
text: l10n.wizardMapFeature3,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.draw,
|
|
||||||
text: l10n.wizardMapFeature4,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -332,20 +274,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
color: iconColor.withValues(alpha: 0.1),
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, size: 80, color: iconColor),
|
||||||
icon,
|
|
||||||
size: 80,
|
|
||||||
color: iconColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -353,36 +291,33 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (features != null && features.isNotEmpty) ...[
|
if (features != null && features.isNotEmpty) ...[
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
// Features list
|
// Features list
|
||||||
...features.map((feature) => Padding(
|
...features.map(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
(feature) => Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
children: [
|
child: Row(
|
||||||
Icon(
|
children: [
|
||||||
feature.icon,
|
Icon(feature.icon, color: colorScheme.primary, size: 24),
|
||||||
color: colorScheme.primary,
|
const SizedBox(width: 16),
|
||||||
size: 24,
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
const SizedBox(width: 16),
|
feature.text,
|
||||||
Expanded(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
child: Text(
|
color: colorScheme.onSurface,
|
||||||
feature.text,
|
|
||||||
style:
|
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class LocationTrackingService {
|
|||||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||||
static const String _prefKeyLastLat = 'background_last_lat';
|
static const String _prefKeyLastLat = 'background_last_lat';
|
||||||
static const String _prefKeyLastLon = 'background_last_lon';
|
static const String _prefKeyLastLon = 'background_last_lon';
|
||||||
|
static const String _prefKeyFastLocationEnabled =
|
||||||
|
'fast_location_updates_enabled';
|
||||||
|
static const String _prefKeyFastMovementThreshold =
|
||||||
|
'fast_location_movement_threshold_meters';
|
||||||
|
static const String _prefKeyFastActiveCadence =
|
||||||
|
'fast_location_active_cadence_seconds';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Configuration Properties
|
// Configuration Properties
|
||||||
@@ -58,6 +64,15 @@ class LocationTrackingService {
|
|||||||
/// GPS update distance filter for position stream
|
/// GPS update distance filter for position stream
|
||||||
double gpsUpdateDistance = 10.0;
|
double gpsUpdateDistance = 10.0;
|
||||||
|
|
||||||
|
/// Whether private fast GPS updates are enabled
|
||||||
|
bool fastLocationUpdatesEnabled = false;
|
||||||
|
|
||||||
|
/// Distance threshold for fast GPS updates
|
||||||
|
double fastLocationMovementThresholdMeters = 10.0;
|
||||||
|
|
||||||
|
/// Cadence for active-use fast GPS updates
|
||||||
|
int fastLocationActiveCadenceSeconds = 10;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Properties
|
// State Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -84,6 +99,11 @@ class LocationTrackingService {
|
|||||||
/// Position stream subscription
|
/// Position stream subscription
|
||||||
StreamSubscription<Position>? _positionSubscription;
|
StreamSubscription<Position>? _positionSubscription;
|
||||||
|
|
||||||
|
Timer? _fastLocationTimer;
|
||||||
|
bool _isFastLocationActiveUse = false;
|
||||||
|
DateTime? _lastFastLocationSentAt;
|
||||||
|
Position? _lastFastLocationSentPosition;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Callback Properties
|
// Callback Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -100,6 +120,9 @@ class LocationTrackingService {
|
|||||||
/// Called when tracking state changes
|
/// Called when tracking state changes
|
||||||
void Function(bool isTracking)? onTrackingStateChanged;
|
void Function(bool isTracking)? onTrackingStateChanged;
|
||||||
|
|
||||||
|
/// Called when a fast private GPS update should be sent
|
||||||
|
void Function(Position position, String reason)? onFastLocationUpdate;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Initialization
|
// Initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -178,7 +201,9 @@ class LocationTrackingService {
|
|||||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
debugPrint(
|
||||||
|
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
|
||||||
|
);
|
||||||
// Exponential backoff: wait 2^attempt seconds before retry
|
// Exponential backoff: wait 2^attempt seconds before retry
|
||||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||||
}
|
}
|
||||||
@@ -192,21 +217,29 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
currentPosition = position;
|
currentPosition = position;
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
debugPrint(
|
||||||
|
'✅ [LocationTracking] Position acquired after $attempt retries',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return position;
|
return position;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final isLastAttempt = attempt == retryCount;
|
final isLastAttempt = attempt == retryCount;
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
debugPrint(
|
||||||
|
'❌ [LocationTracking] Failed to get position after $retryCount retries: $e',
|
||||||
|
);
|
||||||
// Only call error callback on final failure, and make it user-friendly
|
// Only call error callback on final failure, and make it user-friendly
|
||||||
if (e.toString().contains('TimeoutException')) {
|
if (e.toString().contains('TimeoutException')) {
|
||||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
onError?.call(
|
||||||
|
'GPS signal weak. Position stream will continue trying...',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
onError?.call('Failed to get GPS position. Check device settings.');
|
onError?.call('Failed to get GPS position. Check device settings.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
@@ -244,16 +277,16 @@ class LocationTrackingService {
|
|||||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
/// 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) {
|
if (!_isInitialized) {
|
||||||
debugPrint(
|
debugPrint('⚠️ [LocationTracking] Service not initialized');
|
||||||
'⚠️ [LocationTracking] Service not initialized',
|
|
||||||
);
|
|
||||||
onError?.call('Location tracking service not initialized');
|
onError?.call('Location tracking service not initialized');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
debugPrint(
|
||||||
|
'ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
@@ -271,17 +304,20 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
// Try to get initial position in background (non-blocking)
|
// Try to get initial position in background (non-blocking)
|
||||||
// This will populate currentPosition but won't block tracking startup
|
// This will populate currentPosition but won't block tracking startup
|
||||||
getCurrentPosition(
|
getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
|
||||||
timeLimit: const Duration(seconds: 10),
|
.then((position) {
|
||||||
retryCount: 1,
|
if (position != null) {
|
||||||
).then((position) {
|
debugPrint(
|
||||||
if (position != null) {
|
'✅ [LocationTracking] Initial position acquired in background',
|
||||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
);
|
||||||
}
|
}
|
||||||
}).catchError((error) {
|
})
|
||||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
.catchError((error) {
|
||||||
// Not critical - position stream will eventually provide position
|
debugPrint(
|
||||||
});
|
'⚠️ [LocationTracking] Background initial position failed: $error',
|
||||||
|
);
|
||||||
|
// Not critical - position stream will eventually provide position
|
||||||
|
});
|
||||||
|
|
||||||
// Start position stream immediately (don't wait for initial position)
|
// Start position stream immediately (don't wait for initial position)
|
||||||
try {
|
try {
|
||||||
@@ -296,6 +332,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = true;
|
isTracking = true;
|
||||||
onTrackingStateChanged?.call(true);
|
onTrackingStateChanged?.call(true);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||||
@@ -318,6 +355,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = false;
|
isTracking = false;
|
||||||
onTrackingStateChanged?.call(false);
|
onTrackingStateChanged?.call(false);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
// Reset first position flag so next connection starts fresh
|
// Reset first position flag so next connection starts fresh
|
||||||
_firstPositionSet = false;
|
_firstPositionSet = false;
|
||||||
@@ -361,6 +399,8 @@ class LocationTrackingService {
|
|||||||
// Notify listeners
|
// Notify listeners
|
||||||
onPositionUpdate?.call(position);
|
onPositionUpdate?.call(position);
|
||||||
|
|
||||||
|
_evaluateFastLocationMovement(position);
|
||||||
|
|
||||||
// SPECIAL CASE: First stable position after connection
|
// SPECIAL CASE: First stable position after connection
|
||||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||||
if (!_firstPositionSet) {
|
if (!_firstPositionSet) {
|
||||||
@@ -378,12 +418,16 @@ class LocationTrackingService {
|
|||||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||||
void _setInitialPosition(Position position) async {
|
void _setInitialPosition(Position position) async {
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
debugPrint(
|
||||||
|
'📍 [LocationTracking] Setting initial position (no broadcast)',
|
||||||
|
);
|
||||||
|
|
||||||
// Update device's advertised location WITHOUT sending advertisement
|
// Update device's advertised location WITHOUT sending advertisement
|
||||||
await _bleService!.setAdvertLatLon(
|
await _bleService!.setAdvertLatLon(
|
||||||
@@ -414,7 +458,94 @@ class LocationTrackingService {
|
|||||||
void _checkAndBroadcast(Position position) {
|
void _checkAndBroadcast(Position position) {
|
||||||
// Automatic broadcasting disabled
|
// Automatic broadcasting disabled
|
||||||
// Use the manual advert button instead
|
// Use the manual advert button instead
|
||||||
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
|
debugPrint(
|
||||||
|
' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFastLocationActiveUse(bool isActive) {
|
||||||
|
if (_isFastLocationActiveUse == isActive) return;
|
||||||
|
_isFastLocationActiveUse = isActive;
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
|
fastLocationUpdatesEnabled = enabled;
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationMovementThreshold(double meters) async {
|
||||||
|
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
|
||||||
|
await saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
|
||||||
|
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evaluateFastLocationMovement(Position position) {
|
||||||
|
if (!fastLocationUpdatesEnabled) return;
|
||||||
|
final previous = _lastFastLocationSentPosition;
|
||||||
|
if (previous == null) {
|
||||||
|
_emitFastLocationUpdate(position, reason: 'initial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distance = Geolocator.distanceBetween(
|
||||||
|
previous.latitude,
|
||||||
|
previous.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
if (distance >= fastLocationMovementThresholdMeters) {
|
||||||
|
_emitFastLocationUpdate(position, reason: 'movement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshFastLocationTimer() {
|
||||||
|
_fastLocationTimer?.cancel();
|
||||||
|
_fastLocationTimer = null;
|
||||||
|
if (!isTracking ||
|
||||||
|
!fastLocationUpdatesEnabled ||
|
||||||
|
!_isFastLocationActiveUse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fastLocationTimer = Timer.periodic(
|
||||||
|
Duration(seconds: fastLocationActiveCadenceSeconds),
|
||||||
|
(_) {
|
||||||
|
final position = currentPosition;
|
||||||
|
if (position == null) return;
|
||||||
|
_emitFastLocationUpdate(position, reason: 'active_use');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||||
|
if (!fastLocationUpdatesEnabled) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final previous = _lastFastLocationSentPosition;
|
||||||
|
final previousTime = _lastFastLocationSentAt;
|
||||||
|
if (previous != null && previousTime != null) {
|
||||||
|
final distance = Geolocator.distanceBetween(
|
||||||
|
previous.latitude,
|
||||||
|
previous.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
final elapsedMs = now.difference(previousTime).inMilliseconds;
|
||||||
|
if (distance < 1.0 && elapsedMs < 3000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastFastLocationSentPosition = position;
|
||||||
|
_lastFastLocationSentAt = now;
|
||||||
|
onFastLocationUpdate?.call(position, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -457,7 +588,9 @@ class LocationTrackingService {
|
|||||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||||
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
|
debugPrint(
|
||||||
|
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
|
||||||
|
);
|
||||||
onBroadcastSent?.call(position);
|
onBroadcastSent?.call(position);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -480,12 +613,24 @@ class LocationTrackingService {
|
|||||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||||
|
fastLocationUpdatesEnabled =
|
||||||
|
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
|
||||||
|
fastLocationMovementThresholdMeters =
|
||||||
|
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
|
||||||
|
.clamp(1.0, 1000.0);
|
||||||
|
fastLocationActiveCadenceSeconds =
|
||||||
|
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
||||||
|
debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled');
|
||||||
|
debugPrint(
|
||||||
|
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
||||||
|
);
|
||||||
|
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to SharedPreferences
|
/// Save settings to SharedPreferences
|
||||||
@@ -497,6 +642,18 @@ class LocationTrackingService {
|
|||||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||||
|
await prefs.setBool(
|
||||||
|
_prefKeyFastLocationEnabled,
|
||||||
|
fastLocationUpdatesEnabled,
|
||||||
|
);
|
||||||
|
await prefs.setDouble(
|
||||||
|
_prefKeyFastMovementThreshold,
|
||||||
|
fastLocationMovementThresholdMeters,
|
||||||
|
);
|
||||||
|
await prefs.setInt(
|
||||||
|
_prefKeyFastActiveCadence,
|
||||||
|
fastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings saved');
|
debugPrint('✅ [LocationTracking] Settings saved');
|
||||||
}
|
}
|
||||||
@@ -509,6 +666,7 @@ class LocationTrackingService {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||||
_positionSubscription?.cancel();
|
_positionSubscription?.cancel();
|
||||||
|
_fastLocationTimer?.cancel();
|
||||||
_positionSubscription = null;
|
_positionSubscription = null;
|
||||||
_bleService = null;
|
_bleService = null;
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:mbtiles/mbtiles.dart';
|
|
||||||
|
|
||||||
/// Metadata information extracted from an MBTiles file
|
|
||||||
class MbtilesMetadata {
|
|
||||||
final String name;
|
|
||||||
final String? description;
|
|
||||||
final String? version;
|
|
||||||
final String? attribution;
|
|
||||||
final String? bounds; // "minLon,minLat,maxLon,maxLat"
|
|
||||||
final String? center; // "lon,lat,zoom"
|
|
||||||
final int? minZoom;
|
|
||||||
final int? maxZoom;
|
|
||||||
final String? format; // "pbf", "png", "jpg", etc.
|
|
||||||
final String? type; // "overlay", "baselayer"
|
|
||||||
final String? json; // Additional metadata JSON
|
|
||||||
final File file;
|
|
||||||
final int fileSize;
|
|
||||||
|
|
||||||
const MbtilesMetadata({
|
|
||||||
required this.name,
|
|
||||||
this.description,
|
|
||||||
this.version,
|
|
||||||
this.attribution,
|
|
||||||
this.bounds,
|
|
||||||
this.center,
|
|
||||||
this.minZoom,
|
|
||||||
this.maxZoom,
|
|
||||||
this.format,
|
|
||||||
this.type,
|
|
||||||
this.json,
|
|
||||||
required this.file,
|
|
||||||
required this.fileSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Check if this is a vector tile MBTiles file
|
|
||||||
bool get isVector => format == 'pbf' || format == 'mvt';
|
|
||||||
|
|
||||||
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
|
|
||||||
List<double>? get boundsCoordinates {
|
|
||||||
if (bounds == null) return null;
|
|
||||||
try {
|
|
||||||
final parts = bounds!.split(',');
|
|
||||||
if (parts.length != 4) return null;
|
|
||||||
return parts.map((s) => double.parse(s.trim())).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error parsing bounds: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse center string into [lon, lat, zoom]
|
|
||||||
List<double>? get centerCoordinates {
|
|
||||||
if (center == null) return null;
|
|
||||||
try {
|
|
||||||
final parts = center!.split(',');
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
return parts.map((s) => double.parse(s.trim())).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error parsing center: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get file size in human-readable format
|
|
||||||
String get fileSizeFormatted {
|
|
||||||
if (fileSize < 1024) {
|
|
||||||
return '$fileSize B';
|
|
||||||
} else if (fileSize < 1024 * 1024) {
|
|
||||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
|
||||||
} else if (fileSize < 1024 * 1024 * 1024) {
|
|
||||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
||||||
} else {
|
|
||||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service for managing MBTiles files for offline vector maps
|
|
||||||
class MbtilesService {
|
|
||||||
static const String _mbtilesDirectory = 'offline_maps';
|
|
||||||
|
|
||||||
/// Get the directory where MBTiles files are stored
|
|
||||||
Future<Directory> getMbtilesDirectory() async {
|
|
||||||
final appDocDir = await getApplicationDocumentsDirectory();
|
|
||||||
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
|
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if (!await mbtilesDir.exists()) {
|
|
||||||
await mbtilesDir.create(recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mbtilesDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all MBTiles files in the offline maps directory
|
|
||||||
Future<List<File>> listMbtilesFiles() async {
|
|
||||||
final dir = await getMbtilesDirectory();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final files = await dir
|
|
||||||
.list()
|
|
||||||
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
|
|
||||||
.map((entity) => entity as File)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return files;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error listing MBTiles files: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get metadata from an MBTiles file
|
|
||||||
Future<MbtilesMetadata?> getMetadata(File file) async {
|
|
||||||
try {
|
|
||||||
// Check if file exists
|
|
||||||
if (!await file.exists()) {
|
|
||||||
debugPrint('MBTiles file does not exist: ${file.path}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file size
|
|
||||||
final fileSize = await file.length();
|
|
||||||
|
|
||||||
// Open MBTiles file
|
|
||||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
|
||||||
|
|
||||||
// Get metadata from MBTiles
|
|
||||||
final metadata = mbtiles.getMetadata();
|
|
||||||
|
|
||||||
// Convert bounds object to string if available
|
|
||||||
String? boundsStr;
|
|
||||||
if (metadata.bounds != null) {
|
|
||||||
boundsStr = metadata.bounds.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return MbtilesMetadata(
|
|
||||||
name: metadata.name,
|
|
||||||
description: metadata.description,
|
|
||||||
version: metadata.version?.toString(),
|
|
||||||
attribution: null, // Not available in new API
|
|
||||||
bounds: boundsStr,
|
|
||||||
center: null, // Not available in new API
|
|
||||||
minZoom: metadata.minZoom?.toInt(),
|
|
||||||
maxZoom: metadata.maxZoom?.toInt(),
|
|
||||||
format: metadata.format,
|
|
||||||
type: metadata.type?.name,
|
|
||||||
json: null, // Not available in new API
|
|
||||||
file: file,
|
|
||||||
fileSize: fileSize,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get metadata for all MBTiles files
|
|
||||||
Future<List<MbtilesMetadata>> getAllMetadata() async {
|
|
||||||
final files = await listMbtilesFiles();
|
|
||||||
final metadataList = <MbtilesMetadata>[];
|
|
||||||
|
|
||||||
for (final file in files) {
|
|
||||||
final metadata = await getMetadata(file);
|
|
||||||
if (metadata != null) {
|
|
||||||
metadataList.add(metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataList;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import an MBTiles file from an external location
|
|
||||||
Future<File?> importMbtilesFile(String sourcePath) async {
|
|
||||||
try {
|
|
||||||
final sourceFile = File(sourcePath);
|
|
||||||
|
|
||||||
// Verify source file exists
|
|
||||||
if (!await sourceFile.exists()) {
|
|
||||||
debugPrint('Source file does not exist: $sourcePath');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get destination directory
|
|
||||||
final destDir = await getMbtilesDirectory();
|
|
||||||
final fileName = _getFileName(sourceFile);
|
|
||||||
final destPath = '${destDir.path}/$fileName';
|
|
||||||
|
|
||||||
// Copy file to destination
|
|
||||||
final destFile = await sourceFile.copy(destPath);
|
|
||||||
debugPrint('Imported MBTiles file to: $destPath');
|
|
||||||
|
|
||||||
return destFile;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error importing MBTiles file: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an MBTiles file
|
|
||||||
Future<bool> deleteMbtilesFile(File file) async {
|
|
||||||
try {
|
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
debugPrint('Deleted MBTiles file: ${file.path}');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error deleting MBTiles file: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if data in MBTiles is gzip compressed
|
|
||||||
Future<bool> isGzipCompressed(File file) async {
|
|
||||||
try {
|
|
||||||
// Open MBTiles and check a sample tile
|
|
||||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
|
||||||
|
|
||||||
// Try to get metadata to check for compression hints
|
|
||||||
final metadata = mbtiles.getMetadata();
|
|
||||||
final format = metadata.format;
|
|
||||||
|
|
||||||
// For Geofabrik files, format is 'pbf' and data is gzipped
|
|
||||||
// We can infer this from common patterns, but ideally we'd check actual tile data
|
|
||||||
if (format == 'pbf') {
|
|
||||||
// Geofabrik MBTiles are typically gzipped
|
|
||||||
// Could also check tile data headers, but this is a reasonable heuristic
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error checking gzip compression: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the vector tile schema from metadata
|
|
||||||
String? getVectorSchema(MbtilesMetadata metadata) {
|
|
||||||
// Try to infer schema from metadata
|
|
||||||
final json = metadata.json;
|
|
||||||
if (json != null) {
|
|
||||||
if (json.contains('shortbread')) {
|
|
||||||
return 'shortbread';
|
|
||||||
} else if (json.contains('openmaptiles')) {
|
|
||||||
return 'openmaptiles';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check description
|
|
||||||
final description = metadata.description?.toLowerCase();
|
|
||||||
if (description != null) {
|
|
||||||
if (description.contains('shortbread')) {
|
|
||||||
return 'shortbread';
|
|
||||||
} else if (description.contains('openmaptiles')) {
|
|
||||||
return 'openmaptiles';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to unknown
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Get file name from path
|
|
||||||
String _getFileName(File file) {
|
|
||||||
return file.path.split(Platform.pathSeparator).last;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
|
||||||
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
|
|
||||||
import 'package:mbtiles/mbtiles.dart';
|
|
||||||
import '../models/map_layer.dart';
|
|
||||||
|
|
||||||
class TileCacheService {
|
|
||||||
static const String _storeName = 'meshcore_sar_tiles';
|
|
||||||
|
|
||||||
// Global flag to ensure ObjectBox is only initialized once
|
|
||||||
static bool _objectBoxInitialized = false;
|
|
||||||
static final _initLock = <String, Future<void>>{};
|
|
||||||
|
|
||||||
late final FMTCStore _store;
|
|
||||||
bool _isInitialized = false;
|
|
||||||
bool _isDownloading = false;
|
|
||||||
|
|
||||||
Future<void> initialize() async {
|
|
||||||
if (_isInitialized) return;
|
|
||||||
|
|
||||||
// Ensure we only initialize ObjectBox once globally
|
|
||||||
if (!_objectBoxInitialized) {
|
|
||||||
// Use a lock to prevent concurrent initialization attempts
|
|
||||||
final initFuture = _initLock.putIfAbsent('objectbox', () async {
|
|
||||||
try {
|
|
||||||
await FMTCObjectBoxBackend().initialise();
|
|
||||||
_objectBoxInitialized = true;
|
|
||||||
} catch (e) {
|
|
||||||
// Already initialized or error - that's okay
|
|
||||||
_objectBoxInitialized = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await initFuture;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_store = FMTCStore(_storeName);
|
|
||||||
await _store.manage.create();
|
|
||||||
_isInitialized = true;
|
|
||||||
} catch (e) {
|
|
||||||
// Store might already exist
|
|
||||||
_store = FMTCStore(_storeName);
|
|
||||||
_isInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return FMTCTileProvider(
|
|
||||||
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
|
|
||||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
|
||||||
cachedValidDuration: const Duration(days: 30),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get tile provider for WMS layers with caching support
|
|
||||||
/// WMS layers require special handling because they use WMSTileLayerOptions
|
|
||||||
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!layer.isWms) {
|
|
||||||
throw ArgumentError('Layer must be a WMS layer');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the same cached tile provider
|
|
||||||
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
|
|
||||||
return FMTCTileProvider(
|
|
||||||
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
|
|
||||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
|
||||||
cachedValidDuration: const Duration(days: 30),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> downloadRegion({
|
|
||||||
required MapLayer layer,
|
|
||||||
required LatLngBounds bounds,
|
|
||||||
required int minZoom,
|
|
||||||
required int maxZoom,
|
|
||||||
Function(double progress)? onProgress,
|
|
||||||
}) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isDownloading) {
|
|
||||||
throw StateError('A download is already in progress. Cancel it first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
_isDownloading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final region = RectangleRegion(bounds);
|
|
||||||
|
|
||||||
final downloadable = region.toDownloadable(
|
|
||||||
minZoom: minZoom,
|
|
||||||
maxZoom: maxZoom,
|
|
||||||
options: TileLayer(urlTemplate: layer.urlTemplate),
|
|
||||||
);
|
|
||||||
|
|
||||||
final download = _store.download.startForeground(region: downloadable);
|
|
||||||
|
|
||||||
await for (final progress in download.downloadProgress) {
|
|
||||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
|
||||||
// Use attemptedTilesCount instead of successfulTilesCount
|
|
||||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
|
||||||
final percentage = progress.percentageProgress;
|
|
||||||
debugPrint(
|
|
||||||
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
|
|
||||||
);
|
|
||||||
onProgress(percentage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_isDownloading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cancelDownload() async {
|
|
||||||
if (!_isInitialized) return;
|
|
||||||
await _store.download.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearCache() async {
|
|
||||||
if (!_isInitialized) return;
|
|
||||||
await _store.manage.delete();
|
|
||||||
await _store.manage.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> getCachedTileCount() async {
|
|
||||||
if (!_isInitialized) return 0;
|
|
||||||
final stats = await _store.stats.length;
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<double> getCacheSizeMB() async {
|
|
||||||
if (!_isInitialized) return 0.0;
|
|
||||||
final stats = await _store.stats.size;
|
|
||||||
return stats / (1024 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> getAvailableStores() async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final stores = await FMTCRoot.stats.storesAvailable;
|
|
||||||
return stores.map((store) => store.storeName).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getStoreStats() async {
|
|
||||||
if (!_isInitialized) return {};
|
|
||||||
|
|
||||||
final length = await _store.stats.length;
|
|
||||||
final size = await _store.stats.all.then((a) => a.size);
|
|
||||||
|
|
||||||
return {
|
|
||||||
'tileCount': length,
|
|
||||||
'sizeMB': size / 1024,
|
|
||||||
'storeName': _storeName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get vector tile provider for MBTiles layers
|
|
||||||
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
|
|
||||||
if (!layer.isVector || layer.mbtilesFile == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final mbtiles = MbTiles(
|
|
||||||
mbtilesPath: layer.mbtilesFile!.path,
|
|
||||||
gzip: layer.isGzipped ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MbTilesVectorTileProvider(
|
|
||||||
mbtiles: mbtiles,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error creating vector tile provider: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export the current tile cache store to an archive file
|
|
||||||
///
|
|
||||||
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
|
|
||||||
///
|
|
||||||
/// Returns the number of tiles exported
|
|
||||||
Future<int> exportStore(String outputPath) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: outputPath);
|
|
||||||
final result = await external.export(storeNames: [_storeName]);
|
|
||||||
|
|
||||||
debugPrint('Export completed: $result tiles exported to $outputPath');
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error exporting store: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import a tile cache store from an archive file
|
|
||||||
///
|
|
||||||
/// [filePath] - Path to the .fmtc archive file to import
|
|
||||||
/// [storeNames] - Optional list of store names to import (null = import all)
|
|
||||||
/// [strategy] - Conflict resolution strategy (default: merge)
|
|
||||||
///
|
|
||||||
/// Returns a map with import statistics (e.g., tile count, stores imported)
|
|
||||||
Future<Map<String, dynamic>> importStore(
|
|
||||||
String filePath, {
|
|
||||||
List<String>? storeNames,
|
|
||||||
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
|
|
||||||
}) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: filePath);
|
|
||||||
final result = external.import(storeNames: storeNames, strategy: strategy);
|
|
||||||
|
|
||||||
// Wait for the import to complete and get tile count
|
|
||||||
final tileCount = await result.complete;
|
|
||||||
|
|
||||||
// Wait for store states
|
|
||||||
final storesToStates = await result.storesToStates;
|
|
||||||
|
|
||||||
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
|
|
||||||
|
|
||||||
// Count successful stores (those that weren't skipped)
|
|
||||||
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
'successfulStores': successfulCount,
|
|
||||||
'tileCount': tileCount,
|
|
||||||
'storesToStates': storesToStates,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error importing store: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all stores available in an archive file without importing
|
|
||||||
///
|
|
||||||
/// [filePath] - Path to the .fmtc archive file to inspect
|
|
||||||
///
|
|
||||||
/// Returns a list of store names contained in the archive
|
|
||||||
Future<List<String>> listArchiveStores(String filePath) async {
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: filePath);
|
|
||||||
final stores = await external.listStores;
|
|
||||||
debugPrint('Archive contains ${stores.length} stores: $stores');
|
|
||||||
return stores;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error listing archive stores: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_isInitialized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
lib/utils/fast_gps_packet.dart
Normal file
69
lib/utils/fast_gps_packet.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class FastGpsPacket {
|
||||||
|
static const int magic = 0x47; // 'G'
|
||||||
|
static const int _payloadLength = 19;
|
||||||
|
// Store coordinates in microdegrees. This preserves sub-meter precision,
|
||||||
|
// which comfortably satisfies the meter-accuracy requirement.
|
||||||
|
static const double coordinateScale = 1e6;
|
||||||
|
|
||||||
|
final String senderKey6;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final int timestampSeconds;
|
||||||
|
|
||||||
|
const FastGpsPacket({
|
||||||
|
required this.senderKey6,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.timestampSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool isFastGpsBinary(Uint8List payload) =>
|
||||||
|
payload.length == _payloadLength && payload[0] == magic;
|
||||||
|
|
||||||
|
static FastGpsPacket? tryParseBinary(Uint8List payload) {
|
||||||
|
if (!isFastGpsBinary(payload)) return null;
|
||||||
|
|
||||||
|
final key6 = payload
|
||||||
|
.sublist(1, 7)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final data = ByteData.sublistView(payload);
|
||||||
|
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
||||||
|
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
|
||||||
|
final timestampSeconds = data.getUint32(15, Endian.little);
|
||||||
|
|
||||||
|
if (!_isValidCoordinate(latitude, longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FastGpsPacket(
|
||||||
|
senderKey6: key6,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
final out = Uint8List(_payloadLength);
|
||||||
|
final data = ByteData.sublistView(out);
|
||||||
|
out[0] = magic;
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
||||||
|
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
|
||||||
|
data.setUint32(15, timestampSeconds, Endian.little);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isValidCoordinate(double latitude, double longitude) {
|
||||||
|
if (!latitude.isFinite || !longitude.isFinite) return false;
|
||||||
|
return latitude >= -90.0 &&
|
||||||
|
latitude <= 90.0 &&
|
||||||
|
longitude >= -180.0 &&
|
||||||
|
longitude <= 180.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,18 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
|
||||||
|
class ContactRouteDialogResult {
|
||||||
|
final ParsedContactRoute? route;
|
||||||
|
final bool shouldClear;
|
||||||
|
|
||||||
|
const ContactRouteDialogResult._({this.route, required this.shouldClear});
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||||
|
: this._(route: route, shouldClear: false);
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||||
|
}
|
||||||
|
|
||||||
class ContactRouteDialog extends StatefulWidget {
|
class ContactRouteDialog extends StatefulWidget {
|
||||||
final Contact contact;
|
final Contact contact;
|
||||||
final List<Contact> availableContacts;
|
final List<Contact> availableContacts;
|
||||||
@@ -13,12 +25,12 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
required this.availableContacts,
|
required this.availableContacts,
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<ParsedContactRoute?> show(
|
static Future<ContactRouteDialogResult?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Contact contact,
|
required Contact contact,
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<ParsedContactRoute>(
|
return showModalBottomSheet<ContactRouteDialogResult>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
@@ -212,11 +224,20 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
|
if (widget.contact.routeHasPath)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
|
child: const Text('Clear Route'),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _parsedRoute == null
|
onPressed: _parsedRoute == null
|
||||||
? null
|
? null
|
||||||
: () => Navigator.of(context).pop(_parsedRoute),
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
|
||||||
child: const Text('Set Route'),
|
child: const Text('Set Route'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import '../../models/room_login_state.dart';
|
|||||||
import '../../providers/connection_provider.dart';
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import '../../providers/app_provider.dart';
|
|
||||||
import 'contact_route_dialog.dart';
|
import 'contact_route_dialog.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
import '../../utils/location_formats.dart';
|
import '../../utils/location_formats.dart';
|
||||||
import '../../utils/toast_logger.dart';
|
import '../../utils/toast_logger.dart';
|
||||||
import '../../utils/battery_display_helper.dart';
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ContactTile extends StatelessWidget {
|
class ContactTile extends StatelessWidget {
|
||||||
@@ -45,12 +44,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appProvider = context.watch<AppProvider>();
|
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
|
|
||||||
final hasTelemetry =
|
|
||||||
contact.telemetry != null && contact.telemetry!.isRecent;
|
|
||||||
final battery = contact.displayBattery;
|
|
||||||
final location = contact.displayLocation;
|
final location = contact.displayLocation;
|
||||||
// Calculate distance if both positions are available
|
// Calculate distance if both positions are available
|
||||||
String? distanceText;
|
String? distanceText;
|
||||||
@@ -101,13 +94,11 @@ class ContactTile extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
void handleTap() {
|
void handleTap() {
|
||||||
if (isSimpleMode && contact.type == ContactType.chat) {
|
if (contact.type == ContactType.chat) {
|
||||||
_showSetRouteDialog(context, contact);
|
_showSetRouteDialog(context, contact);
|
||||||
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
} else if (contact.type == ContactType.repeater) {
|
||||||
_jumpToMapForRepeater(context, contact);
|
_jumpToMapForRepeater(context, contact);
|
||||||
} else if (isSimpleMode &&
|
} else if (contact.type == ContactType.room && !contact.isPublicChannel) {
|
||||||
contact.type == ContactType.room &&
|
|
||||||
!contact.isPublicChannel) {
|
|
||||||
_showRoomLoginDialog(context, contact);
|
_showRoomLoginDialog(context, contact);
|
||||||
} else {
|
} else {
|
||||||
_showContactDetails(context, contact);
|
_showContactDetails(context, contact);
|
||||||
@@ -147,204 +138,40 @@ class ContactTile extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
letterSpacing: -0.3,
|
letterSpacing: -0.3,
|
||||||
);
|
);
|
||||||
final subtitleWidget = isSimpleMode
|
final timeAgoText = _getLocalizedTimeSinceLastSeen(context);
|
||||||
? Column(
|
final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
color: contact.isRecentlySeen
|
||||||
children: [
|
? colorScheme.primary
|
||||||
const SizedBox(height: 2),
|
: colorScheme.onSurfaceVariant,
|
||||||
Row(
|
fontWeight: FontWeight.w600,
|
||||||
children: [
|
);
|
||||||
Icon(
|
final subtitleWidget = Column(
|
||||||
Icons.access_time,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
size: 12,
|
children: [
|
||||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
if (location != null) ...[
|
||||||
),
|
const SizedBox(height: 2),
|
||||||
const SizedBox(width: 4),
|
_buildLocationLine(
|
||||||
Text(
|
context,
|
||||||
'${AppLocalizations.of(context)!.lastSeen}: ${_getLocalizedTimeSinceLastSeen(context)}',
|
latitude: location.latitude,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
longitude: location.longitude,
|
||||||
),
|
distanceText: distanceText,
|
||||||
],
|
),
|
||||||
),
|
if (contact.type != ContactType.channel) ...[
|
||||||
if (location != null) ...[
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 4),
|
Row(children: [_buildRoutePill(context, contact)]),
|
||||||
Row(
|
],
|
||||||
children: [
|
] else
|
||||||
const Icon(Icons.location_on, size: 12, color: Colors.blue),
|
Padding(
|
||||||
const SizedBox(width: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
Expanded(
|
child: Text(
|
||||||
child: Text(
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
style: Theme.of(
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
context,
|
||||||
overflow: TextOverflow.ellipsis,
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
if (contact.type != ContactType.channel ||
|
|
||||||
distanceText != null) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 6,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (contact.type != ContactType.channel)
|
|
||||||
_buildRoutePill(context, contact),
|
|
||||||
if (distanceText != null)
|
|
||||||
_buildDistancePill(context, distanceText),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
] else
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4),
|
|
||||||
child: Text(
|
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (roomLoginState.isAdmin)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 4,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.red.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(color: Colors.red, width: 1),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.admin_panel_settings,
|
|
||||||
size: 10,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.admin,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Colors.red,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (roomLoginState.isAdmin) const SizedBox(width: 4),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 4,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(color: Colors.green, width: 1),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.check_circle,
|
|
||||||
size: 10,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.loggedIn,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: Colors.green,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
],
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.access_time,
|
|
||||||
size: 12,
|
|
||||||
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_getLocalizedTimeSinceLastSeen(context),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
if (location != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (hasTelemetry)
|
|
||||||
const Icon(Icons.sensors, size: 12, color: Colors.green)
|
|
||||||
else
|
|
||||||
const Icon(
|
|
||||||
Icons.sensors_off,
|
|
||||||
size: 12,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('•', style: TextStyle(color: Colors.grey)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (contact.type != ContactType.channel ||
|
|
||||||
distanceText != null) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 6,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (contact.type != ContactType.channel)
|
|
||||||
_buildRoutePill(context, contact),
|
|
||||||
if (distanceText != null)
|
|
||||||
_buildDistancePill(context, distanceText),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -378,51 +205,12 @@ class ContactTile extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
ContactAvatar(contact: contact, radius: 24),
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
_getTypeColor(contact.type, context),
|
|
||||||
_getTypeColor(
|
|
||||||
contact.type,
|
|
||||||
context,
|
|
||||||
).withValues(alpha: 0.72),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: _getTypeColor(
|
|
||||||
contact.type,
|
|
||||||
context,
|
|
||||||
).withValues(alpha: 0.22),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, 3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: contact.roleEmoji != null
|
|
||||||
? Text(
|
|
||||||
contact.roleEmoji!,
|
|
||||||
style: const TextStyle(fontSize: 22),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_getTypeIcon(contact.type),
|
|
||||||
color: Colors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (contact.isNew)
|
if (contact.isNew)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -2,
|
top: -2,
|
||||||
@@ -480,30 +268,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Text(timeAgoText, style: timeAgoStyle),
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.75),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
contact.type.displayName,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
|
||||||
?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!isSimpleMode && battery != null) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildBatteryBadge(context, battery),
|
|
||||||
],
|
|
||||||
if (isPingInProgress) ...[
|
if (isPingInProgress) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -656,18 +421,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
ContactAvatar(contact: contact, radius: 20),
|
||||||
backgroundColor: _getTypeColor(contact.type, context),
|
|
||||||
child: contact.roleEmoji != null
|
|
||||||
? Text(
|
|
||||||
contact.roleEmoji!,
|
|
||||||
style: const TextStyle(fontSize: 24),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_getTypeIcon(contact.type),
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -1116,17 +870,42 @@ class ContactTile extends StatelessWidget {
|
|||||||
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
|
.where((candidate) => candidate.publicKeyHex != contact.publicKeyHex)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final parsedRoute = await ContactRouteDialog.show(
|
final routeResult = await ContactRouteDialog.show(
|
||||||
context,
|
context,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
availableContacts: availableContacts,
|
availableContacts: availableContacts,
|
||||||
);
|
);
|
||||||
if (parsedRoute == null || !context.mounted) {
|
if (routeResult == null || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final previousSignedPathLen = contact.routeSignedPathLen;
|
final previousSignedPathLen = contact.routeSignedPathLen;
|
||||||
final previousPathBytes = Uint8List.fromList(contact.outPath);
|
final previousPathBytes = Uint8List.fromList(contact.outPath);
|
||||||
|
if (routeResult.shouldClear) {
|
||||||
|
contactsProvider.resetContactRouteLocal(contact.publicKey);
|
||||||
|
await connectionProvider.resetPath(contact.publicKey);
|
||||||
|
final error = connectionProvider.error;
|
||||||
|
if (error != null) {
|
||||||
|
contactsProvider.setContactRouteLocal(
|
||||||
|
contact.publicKey,
|
||||||
|
signedEncodedPathLen: previousSignedPathLen,
|
||||||
|
paddedPathBytes: previousPathBytes,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastLogger.error(context, 'Failed to clear route: $error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Route cleared')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parsedRoute = routeResult.route!;
|
||||||
contactsProvider.setContactRouteLocal(
|
contactsProvider.setContactRouteLocal(
|
||||||
contact.publicKey,
|
contact.publicKey,
|
||||||
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
|
||||||
@@ -1217,6 +996,73 @@ class ContactTile extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationMeta(
|
||||||
|
BuildContext context,
|
||||||
|
double latitude,
|
||||||
|
double longitude, {
|
||||||
|
bool telemetryActive = true,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: telemetryActive
|
||||||
|
? colorScheme.primary.withValues(alpha: 0.12)
|
||||||
|
: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
telemetryActive
|
||||||
|
? Icons.navigation_rounded
|
||||||
|
: Icons.location_searching,
|
||||||
|
size: 11,
|
||||||
|
color: telemetryActive
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLocationLine(
|
||||||
|
BuildContext context, {
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
String? distanceText,
|
||||||
|
bool telemetryActive = true,
|
||||||
|
}) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
_buildLocationMeta(
|
||||||
|
context,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
telemetryActive: telemetryActive,
|
||||||
|
),
|
||||||
|
if (distanceText != null) _buildDistancePill(context, distanceText),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert to Degrees Minutes Seconds (DMS) format
|
/// Convert to Degrees Minutes Seconds (DMS) format
|
||||||
String _convertToDMS(double lat, double lon) {
|
String _convertToDMS(double lat, double lon) {
|
||||||
String latDir = lat >= 0 ? 'N' : 'S';
|
String latDir = lat >= 0 ? 'N' : 'S';
|
||||||
@@ -1273,19 +1119,6 @@ class ContactTile extends StatelessWidget {
|
|||||||
return '$zone$letter (approximate)';
|
return '$zone$letter (approximate)';
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getTypeIcon(ContactType type) {
|
|
||||||
switch (type) {
|
|
||||||
case ContactType.chat:
|
|
||||||
return Icons.person;
|
|
||||||
case ContactType.repeater:
|
|
||||||
return Icons.router;
|
|
||||||
case ContactType.room:
|
|
||||||
return Icons.tag;
|
|
||||||
default:
|
|
||||||
return Icons.help;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getTypeColor(ContactType type, BuildContext context) {
|
Color _getTypeColor(ContactType type, BuildContext context) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ContactType.chat:
|
case ContactType.chat:
|
||||||
@@ -1411,6 +1244,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
|
|
||||||
Widget _buildRoutePill(BuildContext context, Contact contact) {
|
Widget _buildRoutePill(BuildContext context, Contact contact) {
|
||||||
final hasPath = contact.routeHasPath;
|
final hasPath = contact.routeHasPath;
|
||||||
|
final isDirect = !hasPath || contact.routeHopCount <= 0;
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
final textColor = Theme.of(
|
final textColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -1418,9 +1252,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
final iconColor = Theme.of(
|
final iconColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.7);
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.7);
|
||||||
final label = !hasPath
|
final label = isDirect
|
||||||
? AppLocalizations.of(context)!.flood
|
|
||||||
: contact.routeHopCount <= 0
|
|
||||||
? AppLocalizations.of(context)!.direct
|
? AppLocalizations.of(context)!.direct
|
||||||
: contact.routeCanonicalText;
|
: contact.routeCanonicalText;
|
||||||
|
|
||||||
@@ -1434,7 +1266,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
hasPath ? Icons.alt_route : Icons.waves,
|
isDirect ? Icons.north_east_rounded : Icons.alt_route,
|
||||||
size: 11,
|
size: 11,
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
@@ -1447,9 +1279,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontFamily: hasPath && contact.routeHopCount > 0
|
fontFamily: !isDirect ? 'monospace' : null,
|
||||||
? 'monospace'
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1479,7 +1309,7 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
Text(
|
Text(
|
||||||
'${AppLocalizations.of(context)!.distance}: $distanceText',
|
distanceText,
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -1491,33 +1321,4 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBatteryBadge(BuildContext context, double battery) {
|
|
||||||
final color = BatteryDisplayHelper.getBatteryColor(battery);
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
BatteryDisplayHelper.getBatteryIcon(battery),
|
|
||||||
size: 12,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'${battery.round()}%',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: color,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
|
|
||||||
/// Overlay widget that displays controls for download area selection.
|
|
||||||
/// The actual polygon should be rendered inside FlutterMap's children.
|
|
||||||
class DownloadAreaOverlay extends StatelessWidget {
|
|
||||||
final LatLngBounds bounds;
|
|
||||||
final VoidCallback onConfirm;
|
|
||||||
final VoidCallback onCancel;
|
|
||||||
|
|
||||||
const DownloadAreaOverlay({
|
|
||||||
super.key,
|
|
||||||
required this.bounds,
|
|
||||||
required this.onConfirm,
|
|
||||||
required this.onCancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
// Control buttons at the top
|
|
||||||
Positioned(
|
|
||||||
top: 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Download Area Selection',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'The blue rectangle shows the area to be downloaded. '
|
|
||||||
'To change the area, tap Cancel and select download again.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: onCancel,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
label: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: onConfirm,
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
label: const Text('Confirm'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Area info at the bottom
|
|
||||||
Positioned(
|
|
||||||
bottom: 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Area Bounds',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'N: ${bounds.north.toStringAsFixed(4)}° '
|
|
||||||
'S: ${bounds.south.toStringAsFixed(4)}°',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'E: ${bounds.east.toStringAsFixed(4)}° '
|
|
||||||
'W: ${bounds.west.toStringAsFixed(4)}°',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,8 @@ import '../../l10n/app_localizations.dart';
|
|||||||
class DrawingLayer extends StatelessWidget {
|
class DrawingLayer extends StatelessWidget {
|
||||||
final List<MapDrawing> drawings;
|
final List<MapDrawing> drawings;
|
||||||
final MapDrawing? previewDrawing;
|
final MapDrawing? previewDrawing;
|
||||||
final bool isSimpleMode;
|
|
||||||
|
|
||||||
const DrawingLayer({
|
const DrawingLayer({super.key, required this.drawings, this.previewDrawing});
|
||||||
super.key,
|
|
||||||
required this.drawings,
|
|
||||||
this.previewDrawing,
|
|
||||||
this.isSimpleMode = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -48,8 +42,7 @@ class DrawingLayer extends StatelessWidget {
|
|||||||
strokeWidth = 4.0;
|
strokeWidth = 4.0;
|
||||||
} else if (drawing.isReceived) {
|
} else if (drawing.isReceived) {
|
||||||
// Received drawing from another node
|
// Received drawing from another node
|
||||||
// In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7)
|
opacity = 1.0;
|
||||||
opacity = isSimpleMode ? 1.0 : 0.7;
|
|
||||||
strokeWidth = 3.0;
|
strokeWidth = 3.0;
|
||||||
} else {
|
} else {
|
||||||
// Local drawing (solid line, normal thickness)
|
// Local drawing (solid line, normal thickness)
|
||||||
@@ -87,7 +80,6 @@ class DrawingMarkersLayer extends StatelessWidget {
|
|||||||
final Function(String drawingId)? onDeleteDrawing;
|
final Function(String drawingId)? onDeleteDrawing;
|
||||||
final Function(MapDrawing drawing)? onTapDrawing;
|
final Function(MapDrawing drawing)? onTapDrawing;
|
||||||
final bool showDeleteButtons;
|
final bool showDeleteButtons;
|
||||||
final bool isSimpleMode;
|
|
||||||
|
|
||||||
const DrawingMarkersLayer({
|
const DrawingMarkersLayer({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -95,7 +87,6 @@ class DrawingMarkersLayer extends StatelessWidget {
|
|||||||
this.onDeleteDrawing,
|
this.onDeleteDrawing,
|
||||||
this.onTapDrawing,
|
this.onTapDrawing,
|
||||||
this.showDeleteButtons = false,
|
this.showDeleteButtons = false,
|
||||||
this.isSimpleMode = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -132,73 +123,7 @@ class DrawingMarkersLayer extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||||
Icons.close,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) {
|
|
||||||
// Show sender badge for received drawings (when not in drawing mode and not in simple mode)
|
|
||||||
// Make it tappable if message ID is available
|
|
||||||
markers.add(
|
|
||||||
Marker(
|
|
||||||
point: centerPoint,
|
|
||||||
width: 120,
|
|
||||||
height: 30,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: drawing.messageId != null && onTapDrawing != null
|
|
||||||
? () => onTapDrawing!(drawing)
|
|
||||||
: null,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: drawing.color.withValues(alpha: 0.9),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.person,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 14,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
drawing.senderName!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Add indicator that this is tappable
|
|
||||||
if (drawing.messageId != null && onTapDrawing != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Icon(
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 10,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -236,9 +161,7 @@ class DrawingMarkersLayer extends StatelessWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(AppLocalizations.of(context)!.deleteDrawing),
|
title: Text(AppLocalizations.of(context)!.deleteDrawing),
|
||||||
content: Text(
|
content: Text('Delete this ${drawing.type.name}?'),
|
||||||
'Delete this ${drawing.type.name}?',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
@@ -249,9 +172,7 @@ class DrawingMarkersLayer extends StatelessWidget {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
onDeleteDrawing?.call(drawing.id);
|
onDeleteDrawing?.call(drawing.id);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
foregroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
child: Text(AppLocalizations.of(context)!.delete),
|
child: Text(AppLocalizations.of(context)!.delete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../providers/map_provider.dart';
|
import '../../providers/map_provider.dart';
|
||||||
import '../../providers/contacts_provider.dart';
|
import '../../providers/contacts_provider.dart';
|
||||||
import '../../providers/app_provider.dart';
|
|
||||||
import '../../services/gpx_service.dart';
|
|
||||||
import '../../services/trail_color_service.dart';
|
import '../../services/trail_color_service.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -13,10 +11,11 @@ class TrailControls extends StatelessWidget {
|
|||||||
|
|
||||||
void _showTrailMenu(BuildContext context) {
|
void _showTrailMenu(BuildContext context) {
|
||||||
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
final mapProvider = Provider.of<MapProvider>(context, listen: false);
|
||||||
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false);
|
final contactsProvider = Provider.of<ContactsProvider>(
|
||||||
final appProvider = Provider.of<AppProvider>(context, listen: false);
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
|
|
||||||
// Get contacts with trails (advertHistory >= 2 points)
|
// Get contacts with trails (advertHistory >= 2 points)
|
||||||
final contactsWithTrails = contactsProvider.contactsWithLocation
|
final contactsWithTrails = contactsProvider.contactsWithLocation
|
||||||
@@ -34,258 +33,232 @@ class TrailControls extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
|
||||||
const Icon(Icons.timeline, size: 24),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
l10n.locationTrail,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Trail visibility toggle
|
|
||||||
SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.visibility),
|
|
||||||
title: Text(l10n.showTrailOnMap),
|
|
||||||
subtitle: Text(
|
|
||||||
mapProvider.isTrailVisible
|
|
||||||
? l10n.trailVisible
|
|
||||||
: l10n.trailHiddenRecording,
|
|
||||||
),
|
|
||||||
value: mapProvider.isTrailVisible,
|
|
||||||
onChanged: (value) {
|
|
||||||
mapProvider.toggleTrailVisibility();
|
|
||||||
setModalState(() {}); // Update modal UI
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// 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: [
|
children: [
|
||||||
_buildStatRow(
|
const Icon(Icons.timeline, size: 24),
|
||||||
icon: Icons.straighten,
|
const SizedBox(width: 12),
|
||||||
label: l10n.distance,
|
Text(
|
||||||
value: _formatDistance(mapProvider.totalTrailDistance),
|
l10n.locationTrail,
|
||||||
),
|
style: const TextStyle(
|
||||||
const SizedBox(height: 8),
|
fontSize: 20,
|
||||||
_buildStatRow(
|
fontWeight: FontWeight.bold,
|
||||||
icon: Icons.access_time,
|
),
|
||||||
label: l10n.duration,
|
|
||||||
value: _formatDuration(mapProvider.trailDuration),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildStatRow(
|
|
||||||
icon: Icons.place,
|
|
||||||
label: l10n.points,
|
|
||||||
value: '${mapProvider.currentTrail!.points.length}',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
// Trail visibility toggle
|
||||||
|
SwitchListTile(
|
||||||
// GPX Export/Import buttons (hidden in simple mode)
|
secondary: const Icon(Icons.visibility),
|
||||||
if (!isSimpleMode) ...[
|
title: Text(l10n.showTrailOnMap),
|
||||||
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
subtitle: Text(
|
||||||
ElevatedButton.icon(
|
mapProvider.isTrailVisible
|
||||||
onPressed: () async {
|
? l10n.trailVisible
|
||||||
final success = await GpxService.exportTrailToFile(mapProvider.currentTrail!);
|
: l10n.trailHiddenRecording,
|
||||||
if (context.mounted) {
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
value: mapProvider.isTrailVisible,
|
||||||
SnackBar(
|
onChanged: (value) {
|
||||||
content: Text(success
|
mapProvider.toggleTrailVisibility();
|
||||||
? l10n.trailExportedSuccessfully
|
setModalState(() {}); // Update modal UI
|
||||||
: l10n.failedToExportTrail),
|
|
||||||
backgroundColor: success ? Colors.green : Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.upload),
|
|
||||||
label: Text(l10n.exportTrailToGpx),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
// Trail stats
|
||||||
|
if (mapProvider.currentTrail != null &&
|
||||||
ElevatedButton.icon(
|
mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
onPressed: () async {
|
Container(
|
||||||
try {
|
padding: const EdgeInsets.all(12),
|
||||||
final trail = await GpxService.importTrailFromFile();
|
decoration: BoxDecoration(
|
||||||
if (trail != null && context.mounted) {
|
color: Colors.blue.withValues(alpha: 0.1),
|
||||||
_showImportDialog(context, mapProvider, trail, l10n);
|
borderRadius: BorderRadius.circular(8),
|
||||||
}
|
border: Border.all(
|
||||||
} catch (e) {
|
color: Colors.blue.withValues(alpha: 0.3),
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(l10n.failedToImportTrail(e.toString())),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.download),
|
|
||||||
label: Text(l10n.importTrailFromGpx),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Clear trail button
|
|
||||||
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
_showClearConfirmation(context, mapProvider, l10n);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.delete_outline),
|
|
||||||
label: Text(l10n.clearTrail),
|
|
||||||
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)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.timeline, size: 48, color: Colors.grey),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
l10n.noTrailRecorded,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Text(
|
child: Column(
|
||||||
l10n.startTrackingToRecord,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Contact Trails Section
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.people, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
l10n.contactTrails,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Show All Contact Trails toggle
|
|
||||||
SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.route),
|
|
||||||
title: Text(l10n.showAllContactTrails),
|
|
||||||
subtitle: Text(contactsWithTrails.isEmpty
|
|
||||||
? l10n.noContactsWithLocationHistory
|
|
||||||
: mapProvider.showAllContactTrails
|
|
||||||
? l10n.showingTrailsForContacts(contactsWithTrails.length)
|
|
||||||
: l10n.individualContactTrails),
|
|
||||||
value: mapProvider.showAllContactTrails,
|
|
||||||
onChanged: contactsWithTrails.isNotEmpty
|
|
||||||
? (value) {
|
|
||||||
mapProvider.toggleAllContactTrails();
|
|
||||||
setModalState(() {}); // Update modal UI
|
|
||||||
}
|
|
||||||
: null, // Disable if no contacts with trails
|
|
||||||
),
|
|
||||||
|
|
||||||
// Individual contact trails (when "show all" is OFF)
|
|
||||||
if (!mapProvider.showAllContactTrails && contactsWithTrails.isNotEmpty)
|
|
||||||
ExpansionTile(
|
|
||||||
title: Text(l10n.individualContactTrails),
|
|
||||||
initiallyExpanded: false,
|
|
||||||
children: contactsWithTrails.map((contact) {
|
|
||||||
final trailColor = TrailColorService.getTrailColor(contact);
|
|
||||||
final isVisible = mapProvider.isContactPathVisible(contact.publicKeyHex);
|
|
||||||
|
|
||||||
return SwitchListTile(
|
|
||||||
// Color indicator with emoji
|
|
||||||
secondary: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
if (contact.roleEmoji != null)
|
_buildStatRow(
|
||||||
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 18)),
|
icon: Icons.straighten,
|
||||||
const SizedBox(width: 4),
|
label: l10n.distance,
|
||||||
Container(
|
value: _formatDistance(
|
||||||
width: 16,
|
mapProvider.totalTrailDistance,
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: trailColor,
|
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatRow(
|
||||||
|
icon: Icons.access_time,
|
||||||
|
label: l10n.duration,
|
||||||
|
value: _formatDuration(mapProvider.trailDuration),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildStatRow(
|
||||||
|
icon: Icons.place,
|
||||||
|
label: l10n.points,
|
||||||
|
value: '${mapProvider.currentTrail!.points.length}',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
title: Text(contact.displayName),
|
),
|
||||||
subtitle: Text('${contact.advertHistory.length} points'),
|
|
||||||
value: isVisible,
|
const SizedBox(height: 16),
|
||||||
onChanged: (value) {
|
|
||||||
mapProvider.toggleContactPath(contact.publicKeyHex);
|
// Clear trail button
|
||||||
setModalState(() {}); // Update modal UI
|
if (mapProvider.currentTrail != null &&
|
||||||
|
mapProvider.currentTrail!.points.isNotEmpty)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
_showClearConfirmation(context, mapProvider, l10n);
|
||||||
},
|
},
|
||||||
);
|
icon: const Icon(Icons.delete_outline),
|
||||||
}).toList(),
|
label: Text(l10n.clearTrail),
|
||||||
),
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
// No trail message
|
||||||
|
if (mapProvider.currentTrail == null ||
|
||||||
|
mapProvider.currentTrail!.points.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.timeline,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.noTrailRecorded,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.startTrackingToRecord,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Close button
|
const SizedBox(height: 8),
|
||||||
TextButton(
|
const Divider(),
|
||||||
onPressed: () => Navigator.pop(context),
|
const SizedBox(height: 8),
|
||||||
child: Text(l10n.close),
|
|
||||||
),
|
// Contact Trails Section
|
||||||
],
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.people, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
l10n.contactTrails,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Show All Contact Trails toggle
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: Text(l10n.showAllContactTrails),
|
||||||
|
subtitle: Text(
|
||||||
|
contactsWithTrails.isEmpty
|
||||||
|
? l10n.noContactsWithLocationHistory
|
||||||
|
: mapProvider.showAllContactTrails
|
||||||
|
? l10n.showingTrailsForContacts(
|
||||||
|
contactsWithTrails.length,
|
||||||
|
)
|
||||||
|
: l10n.individualContactTrails,
|
||||||
|
),
|
||||||
|
value: mapProvider.showAllContactTrails,
|
||||||
|
onChanged: contactsWithTrails.isNotEmpty
|
||||||
|
? (value) {
|
||||||
|
mapProvider.toggleAllContactTrails();
|
||||||
|
setModalState(() {}); // Update modal UI
|
||||||
|
}
|
||||||
|
: null, // Disable if no contacts with trails
|
||||||
|
),
|
||||||
|
|
||||||
|
// Individual contact trails (when "show all" is OFF)
|
||||||
|
if (!mapProvider.showAllContactTrails &&
|
||||||
|
contactsWithTrails.isNotEmpty)
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text(l10n.individualContactTrails),
|
||||||
|
initiallyExpanded: false,
|
||||||
|
children: contactsWithTrails.map((contact) {
|
||||||
|
final trailColor = TrailColorService.getTrailColor(
|
||||||
|
contact,
|
||||||
|
);
|
||||||
|
final isVisible = mapProvider.isContactPathVisible(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SwitchListTile(
|
||||||
|
// Color indicator with emoji
|
||||||
|
secondary: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (contact.roleEmoji != null)
|
||||||
|
Text(
|
||||||
|
contact.roleEmoji!,
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: trailColor,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Text(contact.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'${contact.advertHistory.length} points',
|
||||||
|
),
|
||||||
|
value: isVisible,
|
||||||
|
onChanged: (value) {
|
||||||
|
mapProvider.toggleContactPath(contact.publicKeyHex);
|
||||||
|
setModalState(() {}); // Update modal UI
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l10n.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -293,7 +266,11 @@ class TrailControls extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showClearConfirmation(BuildContext context, MapProvider mapProvider, AppLocalizations l10n) {
|
void _showClearConfirmation(
|
||||||
|
BuildContext context,
|
||||||
|
MapProvider mapProvider,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
@@ -318,50 +295,6 @@ class TrailControls extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showImportDialog(BuildContext context, MapProvider mapProvider, trail, AppLocalizations l10n) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(l10n.importTrail),
|
|
||||||
content: Text(l10n.importTrailQuestion(trail.points.length)),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(l10n.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
mapProvider.setImportedTrail(trail);
|
|
||||||
Navigator.pop(context); // Close dialog
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(l10n.trailImported(trail.points.length)),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(l10n.viewAlongside),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
mapProvider.replaceCurrentTrailWithImport(trail);
|
|
||||||
Navigator.pop(context); // Close dialog
|
|
||||||
Navigator.pop(context); // Close bottom sheet
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(l10n.trailReplaced(trail.points.length)),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.blue),
|
|
||||||
child: Text(l10n.replaceCurrent),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildStatRow({
|
Widget _buildStatRow({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String label,
|
required String label,
|
||||||
@@ -373,18 +306,12 @@ class TrailControls extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
|
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
|
||||||
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
|
|
||||||
#include <record_linux/record_linux_plugin.h>
|
#include <record_linux/record_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
@@ -23,9 +22,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
|
||||||
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
|
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar =
|
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin");
|
|
||||||
objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar);
|
|
||||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
audioplayers_linux
|
audioplayers_linux
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_avif_linux
|
flutter_avif_linux
|
||||||
objectbox_flutter_libs
|
|
||||||
record_linux
|
record_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import flutter_blue_plus_darwin
|
|||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
import path_provider_foundation
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -32,8 +32,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
226
pubspec.lock
226
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
code_assets:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: code_assets
|
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -226,14 +218,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.3"
|
version: "7.0.3"
|
||||||
executor_lib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: executor_lib
|
|
||||||
sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.2"
|
|
||||||
exif:
|
exif:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -314,14 +298,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
flat_buffers:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flat_buffers
|
|
||||||
sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "23.5.26"
|
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -551,19 +527,12 @@ packages:
|
|||||||
flutter_map:
|
flutter_map:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_map
|
path: "."
|
||||||
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
|
ref: master
|
||||||
url: "https://pub.dev"
|
resolved-ref: fdc089aeb4fad05a4f2314f75bbd66c9b7a70668
|
||||||
source: hosted
|
url: "https://github.com/fleaflet/flutter_map.git"
|
||||||
|
source: git
|
||||||
version: "8.2.2"
|
version: "8.2.2"
|
||||||
flutter_map_tile_caching:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_map_tile_caching
|
|
||||||
sha256: "90e097223d8ab74425cf15b449a03adfa4d4c28406dc757e1c396aff0f9beba7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "10.1.1"
|
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -646,14 +615,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -662,14 +623,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
hooks:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hooks
|
|
||||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -694,14 +647,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
idb_shim:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: idb_shim
|
|
||||||
sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.8.2+4"
|
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -830,38 +775,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
lists:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: lists
|
|
||||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
logger:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logger
|
|
||||||
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.6.2"
|
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -870,14 +791,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.13.0"
|
||||||
mbtiles:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: mbtiles
|
|
||||||
sha256: "316af1f8db8ce95888ca70f5dd3f6914906b4e17ceeca8501206d28e78612af8"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.4.2"
|
|
||||||
meshcore_client:
|
meshcore_client:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -899,10 +812,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: mgrs_dart
|
name: mgrs_dart
|
||||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "3.0.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -911,14 +824,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.17.5"
|
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -975,30 +880,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.1"
|
||||||
objectbox:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objectbox
|
|
||||||
sha256: "3cc186749178a3556e1020c9082d0897d0f9ecbdefcc27320e65c5bc650f0e57"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.3.1"
|
|
||||||
objectbox_flutter_libs:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objectbox_flutter_libs
|
|
||||||
sha256: cd754766e04229a4f51250f121813d9a3c1a74fc21cd68e48b3c6085cbcd6c85
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.3.1"
|
|
||||||
objective_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: objective_c
|
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.3.0"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1043,10 +924,10 @@ packages:
|
|||||||
dependency: "direct overridden"
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.0"
|
version: "2.5.1"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1163,10 +1044,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: proj4dart
|
name: proj4dart
|
||||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "3.0.0"
|
||||||
protobuf:
|
protobuf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1183,14 +1064,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: pub_semver
|
|
||||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.2.0"
|
|
||||||
record:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1263,14 +1136,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
version: "0.28.0"
|
||||||
sembast:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sembast
|
|
||||||
sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.8.6"
|
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1359,6 +1224,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.4"
|
version: "1.1.4"
|
||||||
|
simple_sparse_list:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: simple_sparse_list
|
||||||
|
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.4"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -1420,14 +1293,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
sqlite3:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqlite3
|
|
||||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.9.4"
|
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1472,10 +1337,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.10"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1496,10 +1361,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: unicode
|
name: unicode
|
||||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.1"
|
version: "1.1.9"
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1572,23 +1437,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
version: "4.5.3"
|
||||||
vector_map_tiles:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: vector_map_tiles
|
|
||||||
sha256: e35f090c428f05e44dd525fa4fedaafd1dbcd28b656cb0ea908528c6ce84a87d
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "9.0.0-beta.8"
|
|
||||||
vector_map_tiles_mbtiles:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
path: vector_map_tiles_mbtiles
|
|
||||||
ref: HEAD
|
|
||||||
resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6
|
|
||||||
url: "https://github.com/josxha/flutter_map_plugins.git"
|
|
||||||
source: git
|
|
||||||
version: "1.2.1"
|
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1597,30 +1445,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
vector_tile:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vector_tile
|
|
||||||
sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.1"
|
|
||||||
vector_tile_dem:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: vector_tile_dem
|
|
||||||
sha256: "81a3568d2213817bd2698f919357e5107c0261491ae1014e821ed4fc3c2bf740"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.2"
|
|
||||||
vector_tile_renderer:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: vector_tile_renderer
|
|
||||||
sha256: "99530edb073c1cea3c6a4bdb5ca9a5c6779c25ddf1a645678458504708dc221c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.0.0"
|
|
||||||
vibration:
|
vibration:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
25
pubspec.yaml
25
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0307.6+19
|
version: 2026.0308.1+21
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -66,24 +66,16 @@ dependencies:
|
|||||||
provider: ^6.1.0
|
provider: ^6.1.0
|
||||||
|
|
||||||
# Map display
|
# Map display
|
||||||
flutter_map: ^8.2.2
|
flutter_map:
|
||||||
|
git:
|
||||||
|
url: https://github.com/fleaflet/flutter_map.git
|
||||||
|
ref: master
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
|
|
||||||
# Offline tile caching
|
|
||||||
flutter_map_tile_caching: ^10.1.1
|
|
||||||
|
|
||||||
# Vector map tiles
|
|
||||||
vector_map_tiles: ^9.0.0-beta.8
|
|
||||||
vector_map_tiles_mbtiles:
|
|
||||||
git:
|
|
||||||
url: https://github.com/josxha/flutter_map_plugins.git
|
|
||||||
path: vector_map_tiles_mbtiles
|
|
||||||
vector_tile_renderer: ^6.0.0
|
|
||||||
mbtiles: ^0.4.0
|
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
|
|
||||||
# Coordinate system projections for WMS (EPSG:3794)
|
# Coordinate system projections for WMS (EPSG:3794)
|
||||||
proj4dart: ^2.1.0
|
proj4dart: ^3.0.0
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
@@ -147,7 +139,10 @@ dev_dependencies:
|
|||||||
fake_async: ^1.3.3
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
path_provider_foundation: 2.6.0
|
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native
|
||||||
|
# asset. That framework has been ending up archived with a macOS platform
|
||||||
|
# slice and fails App Store validation for iOS uploads.
|
||||||
|
path_provider_foundation: 2.5.1
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
android: "launcher_icon"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -241,6 +243,129 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('retains existing telemetry when contact refresh omits telemetry', () {
|
||||||
|
final initialTelemetry = ContactTelemetry(
|
||||||
|
gpsLocation: const LatLng(45.1234, 13.8765),
|
||||||
|
batteryPercentage: 76.5,
|
||||||
|
batteryMilliVolts: 3890,
|
||||||
|
temperature: 21.5,
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
|
||||||
|
humidity: 62.0,
|
||||||
|
pressure: 1008.4,
|
||||||
|
extraSensorData: const {'co2': 415.0},
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(telemetry: initialTelemetry),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Test Contact Refreshed',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
|
||||||
|
expect(updated.telemetry!.batteryPercentage, equals(76.5));
|
||||||
|
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
|
||||||
|
expect(updated.telemetry!.temperature, equals(21.5));
|
||||||
|
expect(updated.telemetry!.humidity, equals(62.0));
|
||||||
|
expect(updated.telemetry!.pressure, equals(1008.4));
|
||||||
|
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains existing telemetry during bulk contacts sync', () {
|
||||||
|
final initialTelemetry = ContactTelemetry(
|
||||||
|
gpsLocation: const LatLng(45.1234, 13.8765),
|
||||||
|
batteryPercentage: 76.5,
|
||||||
|
batteryMilliVolts: 3890,
|
||||||
|
temperature: 21.5,
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
|
||||||
|
humidity: 62.0,
|
||||||
|
pressure: 1008.4,
|
||||||
|
extraSensorData: const {'co2': 415.0},
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(telemetry: initialTelemetry),
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addContacts([
|
||||||
|
Contact(
|
||||||
|
publicKey: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'Synced Contact',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
|
||||||
|
expect(updated.telemetry!.batteryPercentage, equals(76.5));
|
||||||
|
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
|
||||||
|
expect(updated.telemetry!.temperature, equals(21.5));
|
||||||
|
expect(updated.telemetry!.humidity, equals(62.0));
|
||||||
|
expect(updated.telemetry!.pressure, equals(1008.4));
|
||||||
|
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains prior telemetry fields across sparse telemetry updates', () {
|
||||||
|
final fullTelemetry = ContactTelemetry(
|
||||||
|
gpsLocation: const LatLng(46.0569, 14.5058),
|
||||||
|
batteryPercentage: 54.0,
|
||||||
|
batteryMilliVolts: 3780,
|
||||||
|
temperature: 19.5,
|
||||||
|
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
|
||||||
|
humidity: 58.0,
|
||||||
|
pressure: 1011.2,
|
||||||
|
extraSensorData: const {'pm25': 8.0},
|
||||||
|
);
|
||||||
|
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(
|
||||||
|
key: publicKey,
|
||||||
|
type: ContactType.chat,
|
||||||
|
).copyWith(telemetry: fullTelemetry),
|
||||||
|
);
|
||||||
|
|
||||||
|
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058));
|
||||||
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||||
|
expect(updated.telemetry!.batteryPercentage, isNotNull);
|
||||||
|
expect(updated.telemetry!.temperature, equals(19.5));
|
||||||
|
expect(updated.telemetry!.humidity, equals(58.0));
|
||||||
|
expect(updated.telemetry!.pressure, equals(1011.2));
|
||||||
|
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0));
|
||||||
|
});
|
||||||
|
|
||||||
test('builds message snapshot from latest valid telemetry', () {
|
test('builds message snapshot from latest valid telemetry', () {
|
||||||
final telemetryData = CayenneLppParser.createGpsData(
|
final telemetryData = CayenneLppParser.createGpsData(
|
||||||
latitude: 45.0001,
|
latitude: 45.0001,
|
||||||
@@ -347,4 +472,65 @@ void main() {
|
|||||||
expect(updated.routeSummary, 'Flood/Unknown');
|
expect(updated.routeSummary, 'Flood/Unknown');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ContactsProvider.updateFastGps', () {
|
||||||
|
late ContactsProvider provider;
|
||||||
|
late Uint8List publicKey;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
provider = ContactsProvider();
|
||||||
|
publicKey = createPublicKey(50);
|
||||||
|
provider.addOrUpdateContact(
|
||||||
|
createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates gps while preserving other telemetry fields', () {
|
||||||
|
final batteryOnly = CayenneLppParser.createBatteryData(3.9);
|
||||||
|
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
|
||||||
|
|
||||||
|
provider.updateFastGps(
|
||||||
|
publicKey.sublist(0, 6),
|
||||||
|
const FastGpsPacket(
|
||||||
|
senderKey6: '323334353637',
|
||||||
|
latitude: 44.123456,
|
||||||
|
longitude: 13.654321,
|
||||||
|
timestampSeconds: 1700001234,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(updated.telemetry, isNotNull);
|
||||||
|
expect(updated.telemetry!.gpsLocation, isNotNull);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.gpsLocation!.latitude,
|
||||||
|
closeTo(44.123456, 0.000001),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.telemetry!.gpsLocation!.longitude,
|
||||||
|
closeTo(13.654321, 0.000001),
|
||||||
|
);
|
||||||
|
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
|
||||||
|
expect(updated.advLat, equals((44.123456 * 1e6).round()));
|
||||||
|
expect(updated.advLon, equals((13.654321 * 1e6).round()));
|
||||||
|
expect(updated.lastAdvert, equals(1700001234));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores unknown sender prefix safely', () {
|
||||||
|
final before = provider.findContactByKey(publicKey)!;
|
||||||
|
provider.updateFastGps(
|
||||||
|
Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||||
|
const FastGpsPacket(
|
||||||
|
senderKey6: '010203040506',
|
||||||
|
latitude: 10,
|
||||||
|
longitude: 20,
|
||||||
|
timestampSeconds: 99,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final after = provider.findContactByKey(publicKey)!;
|
||||||
|
expect(after.advLat, equals(before.advLat));
|
||||||
|
expect(after.advLon, equals(before.advLon));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
97
test/utils/fast_gps_packet_test.dart
Normal file
97
test/utils/fast_gps_packet_test.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('FastGpsPacket', () {
|
||||||
|
test('encodes and parses a valid packet', () {
|
||||||
|
final packet = FastGpsPacket(
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
latitude: 46.0569,
|
||||||
|
longitude: 14.5058,
|
||||||
|
timestampSeconds: 1700000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final encoded = packet.encodeBinary();
|
||||||
|
final parsed = FastGpsPacket.tryParseBinary(encoded);
|
||||||
|
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.senderKey6, equals('aabbccddeeff'));
|
||||||
|
expect(parsed.latitude, closeTo(46.0569, 0.000001));
|
||||||
|
expect(parsed.longitude, closeTo(14.5058, 0.000001));
|
||||||
|
expect(parsed.timestampSeconds, equals(1700000000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports negative coordinates', () {
|
||||||
|
final packet = FastGpsPacket(
|
||||||
|
senderKey6: '001122334455',
|
||||||
|
latitude: -33.8688,
|
||||||
|
longitude: -151.2093,
|
||||||
|
timestampSeconds: 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(parsed!.latitude, closeTo(-33.8688, 0.000001));
|
||||||
|
expect(parsed.longitude, closeTo(-151.2093, 0.000001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed payloads', () {
|
||||||
|
expect(
|
||||||
|
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FastGpsPacket.tryParseBinary(
|
||||||
|
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x48),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid coordinate ranges', () {
|
||||||
|
final payload = Uint8List(19);
|
||||||
|
payload[0] = FastGpsPacket.magic;
|
||||||
|
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
|
||||||
|
final data = ByteData.sublistView(payload);
|
||||||
|
data.setInt32(
|
||||||
|
7,
|
||||||
|
(91.0 * FastGpsPacket.coordinateScale).round(),
|
||||||
|
Endian.little,
|
||||||
|
);
|
||||||
|
data.setInt32(
|
||||||
|
11,
|
||||||
|
(14.5 * FastGpsPacket.coordinateScale).round(),
|
||||||
|
Endian.little,
|
||||||
|
);
|
||||||
|
data.setUint32(15, 1, Endian.little);
|
||||||
|
|
||||||
|
expect(FastGpsPacket.tryParseBinary(payload), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves at least meter accuracy', () {
|
||||||
|
const latitude = 46.0569123;
|
||||||
|
const longitude = 14.5058123;
|
||||||
|
final packet = FastGpsPacket(
|
||||||
|
senderKey6: 'aabbccddeeff',
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
timestampSeconds: 1700000000,
|
||||||
|
);
|
||||||
|
|
||||||
|
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
|
||||||
|
final latMeters = (parsed!.latitude - latitude).abs() * 111320.0;
|
||||||
|
final lonMeters =
|
||||||
|
(parsed.longitude - longitude).abs() *
|
||||||
|
111320.0 *
|
||||||
|
math.cos(latitude * math.pi / 180.0);
|
||||||
|
|
||||||
|
expect(latMeters, lessThan(1.0));
|
||||||
|
expect(lonMeters, lessThan(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
|
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <nsd_windows/nsd_windows_plugin_c_api.h>
|
#include <nsd_windows/nsd_windows_plugin_c_api.h>
|
||||||
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
|
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <record_windows/record_windows_plugin_c_api.h>
|
#include <record_windows/record_windows_plugin_c_api.h>
|
||||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||||
@@ -31,8 +30,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
NsdWindowsPluginCApiRegisterWithRegistrar(
|
NsdWindowsPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("NsdWindowsPluginCApi"));
|
registry->GetRegistrarForPlugin("NsdWindowsPluginCApi"));
|
||||||
ObjectboxFlutterLibsPluginRegisterWithRegistrar(
|
|
||||||
registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin"));
|
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
flutter_blue_plus_winrt
|
flutter_blue_plus_winrt
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
nsd_windows
|
nsd_windows
|
||||||
objectbox_flutter_libs
|
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
record_windows
|
record_windows
|
||||||
share_plus
|
share_plus
|
||||||
|
|||||||
Reference in New Issue
Block a user