mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 17:00:28 +00:00
Compare commits
113 Commits
v2026.0308
...
v2026.0315
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 | ||
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
e502bc23b7 | ||
|
|
62bf33c9d1 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb | ||
|
|
d113f7a881 | ||
|
|
2ab8681ac4 | ||
|
|
de5fb1e164 | ||
|
|
dae7e67a8a | ||
|
|
d51924021f | ||
|
|
0f91292fd7 | ||
|
|
b471ab0990 | ||
|
|
771cddb80d | ||
|
|
bfd15065f0 | ||
|
|
f72db76538 | ||
|
|
a96f96ca4c | ||
|
|
9f62055eef | ||
|
|
6b2f18130c | ||
|
|
922b40b755 | ||
|
|
17d7c43745 |
2
.github/workflows/build-artifacts.yml
vendored
2
.github/workflows/build-artifacts.yml
vendored
@@ -280,7 +280,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build web release
|
- name: Build web release
|
||||||
run: flutter build web --release --base-href /meshcore_sar_app/
|
run: flutter build web --release --base-href /meshcore-sar/
|
||||||
|
|
||||||
- name: Upload pages artifact
|
- name: Upload pages artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,10 +80,11 @@ create_feature_graphic.py
|
|||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Dart code coverage
|
# Dart code coverage
|
||||||
coverage/
|
coverage/
|
||||||
lcov.info
|
lcov.info
|
||||||
|
|||||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-08 to 2026-03-14
|
||||||
|
|
||||||
|
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
|
||||||
|
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
|
||||||
|
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
|
||||||
|
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
|
||||||
|
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
|
||||||
|
|
||||||
|
## Key PRs
|
||||||
|
|
||||||
|
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
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 = 105;
|
CURRENT_PROJECT_VERSION = 117;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>105</string>
|
<string>117</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -5,24 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
|
||||||
|
|
||||||
<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]
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
1452
lib/l10n/app_de.arb
1452
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2673
lib/l10n/app_el.arb
2673
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
1969
lib/l10n/app_en.arb
1969
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1441
lib/l10n/app_es.arb
1441
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1446
lib/l10n/app_fr.arb
1446
lib/l10n/app_fr.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1446
lib/l10n/app_it.arb
1446
lib/l10n/app_it.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1927
lib/l10n/app_localizations_pl.dart
Normal file
1927
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
1934
lib/l10n/app_localizations_pt.dart
Normal file
1934
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1917
lib/l10n/app_localizations_tr.dart
Normal file
1917
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
1930
lib/l10n/app_localizations_uk.dart
Normal file
1930
lib/l10n/app_localizations_uk.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => '断开连接';
|
String get disconnect => '断开连接';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => '正在扫描设备...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => '未找到设备';
|
String get noDevicesFound => '未找到设备';
|
||||||
|
|
||||||
@@ -59,27 +56,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => '获取 GPS 位置失败';
|
String get failedToGetGpsLocation => '获取 GPS 位置失败';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return '已广播位置 $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return '广播失败: $error';
|
return '广播失败: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return '正在重新连接... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => '取消重新连接';
|
String get cancelReconnection => '取消重新连接';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => '地图管理';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => '通用';
|
String get general => '通用';
|
||||||
|
|
||||||
@@ -125,12 +109,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get displayPacketActivity => '在顶部栏显示数据包活动指示器';
|
String get displayPacketActivity => '在顶部栏显示数据包活动指示器';
|
||||||
|
|
||||||
@override
|
|
||||||
String get simpleMode => '简洁模式';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get simpleModeDescription => '在消息和联系人中隐藏非必要信息';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get disableMap => '禁用地图';
|
String get disableMap => '禁用地图';
|
||||||
|
|
||||||
@@ -143,72 +121,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => '选择语言';
|
String get chooseLanguage => '选择语言';
|
||||||
|
|
||||||
@override
|
|
||||||
String get english => '英语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get slovenian => '斯洛文尼亚语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get croatian => '克罗地亚语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get german => '德语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get spanish => '西班牙语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get french => '法语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get italian => '意大利语';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationBroadcasting => '位置广播';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get autoLocationTracking => '自动位置跟踪';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get automaticallyBroadcastPosition => '自动广播位置更新';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get configureTracking => '配置跟踪';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get distanceAndTimeThresholds => '距离和时间阈值';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationTrackingConfiguration => '位置跟踪配置';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get configureWhenLocationBroadcasts => '配置何时向 Mesh 网络发送位置广播';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get minimumDistance => '最小距离';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String broadcastAfterMoving(String distance) {
|
|
||||||
return '仅在移动 $distance 米后广播';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get maximumDistance => '最大距离';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String alwaysBroadcastAfterMoving(String distance) {
|
|
||||||
return '移动 $distance 米后始终广播';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get minimumTimeInterval => '最小时间间隔';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String alwaysBroadcastEvery(String duration) {
|
|
||||||
return '每隔 $duration 始终广播';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get save => '保存';
|
String get save => '保存';
|
||||||
|
|
||||||
@@ -244,12 +156,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => '更多信息';
|
String get moreInfo => '更多信息';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => '了解更多关于 MeshCore 搜救助手的信息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => '开发者';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => '包名';
|
String get packageName => '包名';
|
||||||
|
|
||||||
@@ -321,9 +227,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => '刷新';
|
String get refresh => '刷新';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => '发送';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => '重置路径 (重新路由)';
|
String get resetPath => '重置路径 (重新路由)';
|
||||||
|
|
||||||
@@ -441,21 +344,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '共享 $count 个绘图';
|
return '共享 $count 个绘图';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String sentDrawingsToRoom(int count, String plural, String roomName) {
|
|
||||||
return '已发送 $count 个地图绘制到 $roomName';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String sharedDrawingsToRoom(
|
|
||||||
int success,
|
|
||||||
int total,
|
|
||||||
String plural,
|
|
||||||
String roomName,
|
|
||||||
) {
|
|
||||||
return '已将 $success/$total 个绘图共享到 $roomName';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get showReceivedDrawings => '显示接收到的绘图';
|
String get showReceivedDrawings => '显示接收到的绘图';
|
||||||
|
|
||||||
@@ -477,9 +365,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => '全部清除';
|
String get clearAll => '全部清除';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => '没有可共享的本地绘图';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => '公共频道';
|
String get publicChannel => '公共频道';
|
||||||
|
|
||||||
@@ -489,37 +374,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => '永久存储在房间中';
|
String get storedPermanently => '永久存储在房间中';
|
||||||
|
|
||||||
@override
|
|
||||||
String drawingsSentToPublicChannel(int count, String plural) {
|
|
||||||
return '已将 $count 个地图绘制发送到公共频道';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String drawingsSharedToPublicChannel(int success, int total) {
|
|
||||||
return '已将 $success/$total 个绘图共享到公共频道';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notConnectedToDevice => '未连接到设备';
|
String get notConnectedToDevice => '未连接到设备';
|
||||||
|
|
||||||
@override
|
|
||||||
String get directMessage => '直连消息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String directMessageSentTo(String contactName) {
|
|
||||||
return '直连消息已发送给 $contactName';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String failedToSend(String error) {
|
|
||||||
return '发送失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String directMessageInfo(String contactName) {
|
|
||||||
return '此消息将直连发送给 $contactName。它也将出现在主消息流中。';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get typeYourMessage => '输入您的消息...';
|
String get typeYourMessage => '输入您的消息...';
|
||||||
|
|
||||||
@@ -547,9 +404,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => '位置';
|
String get location => '位置';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => '我的位置';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => '来自地图';
|
String get fromMap => '来自地图';
|
||||||
|
|
||||||
@@ -610,9 +464,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => '泛洪';
|
String get flood => '泛洪';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => '管理员';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => '已登录';
|
String get loggedIn => '已登录';
|
||||||
|
|
||||||
@@ -622,29 +473,11 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => '距离';
|
String get distance => '距离';
|
||||||
|
|
||||||
@override
|
|
||||||
String pingingDirect(String name) {
|
|
||||||
return '正在 Ping $name(通过路径直连发送)...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String pingingFlood(String name) {
|
|
||||||
return '正在 Ping $name(泛洪 - 无路径)...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return '直连 Ping 超时 - 正在使用泛洪重试 $name...';
|
return '直连 Ping 超时 - 正在使用泛洪重试 $name...';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String pingSuccessful(String name, String fallback) {
|
|
||||||
return '成功 Ping 到 $name$fallback';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get viaFloodingFallback => '(通过泛洪回退)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping $name 失败 - 未收到响应';
|
return 'Ping $name 失败 - 未收到响应';
|
||||||
@@ -655,16 +488,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '您确定要删除“$name”吗?\n\n这将从应用程序和配套的无线电设备中移除该联系人。';
|
return '您确定要删除“$name”吗?\n\n这将从应用程序和配套的无线电设备中移除该联系人。';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String removingContact(String name) {
|
|
||||||
return '正在移除 $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String contactRemoved(String name) {
|
|
||||||
return '联系人“$name”已移除';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return '移除联系人失败:$error';
|
return '移除联系人失败:$error';
|
||||||
@@ -709,11 +532,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => '遥测';
|
String get telemetry => '遥测';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return '正在向 $name 请求遥测数据...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => '电压';
|
String get voltage => '电压';
|
||||||
|
|
||||||
@@ -785,9 +603,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => '显示全部';
|
String get showAll => '显示全部';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => '附近联系人';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => '位置不可用';
|
String get locationUnavailable => '位置不可用';
|
||||||
|
|
||||||
@@ -827,169 +642,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => '频道';
|
String get channels => '频道';
|
||||||
|
|
||||||
@override
|
|
||||||
String get cacheStatistics => '缓存统计';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get totalTiles => '总瓦片数';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cacheSize => '缓存大小';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get storeName => '存储名称';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noCacheStatistics => '没有可用的缓存统计信息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadRegion => '下载区域';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapLayer => '地图图层';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get regionBounds => '区域边界';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get north => '北';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get south => '南';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get east => '东';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get west => '西';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get zoomLevels => '缩放级别';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String minZoom(int zoom) {
|
|
||||||
return '最小:$zoom';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String maxZoom(int zoom) {
|
|
||||||
return '最大:$zoom';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadingDots => '正在下载...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cancelDownload => '取消下载';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadRegionButton => '下载区域';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadNote => '注意:大的区域或高缩放级别可能需要大量时间和存储空间。';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cacheManagement => '缓存管理';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearAllMaps => '清除所有地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearMapsConfirmTitle => '清除所有地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get clearMapsConfirmMessage => '您确定要删除所有下载的地图吗?此操作无法撤销。';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapDownloadCompleted => '地图下载完成!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cacheClearedSuccessfully => '缓存清除成功!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadCancelled => '下载已取消';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get startingDownload => '正在开始下载...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadingMapTiles => '正在下载地图瓦片...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadCompletedSuccessfully => '下载成功完成!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get cancellingDownload => '正在取消下载...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String errorLoadingStats(String error) {
|
|
||||||
return '加载统计信息时出错:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String downloadFailed(String error) {
|
|
||||||
return '下载失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String cancelFailed(String error) {
|
|
||||||
return '取消失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String clearCacheFailed(String error) {
|
|
||||||
return '清除缓存失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String minZoomError(String error) {
|
|
||||||
return '最小缩放:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String maxZoomError(String error) {
|
|
||||||
return '最大缩放:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get minZoomGreaterThanMax => '最小缩放必须小于或等于最大缩放';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get selectMapLayer => '选择地图图层';
|
String get selectMapLayer => '选择地图图层';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapOptions => '地图选项';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showLegend => '显示图例';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get displayMarkerTypeCounts => '显示标记类型计数';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get rotateMapWithHeading => '随航向旋转地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapFollowsDirection => '地图在移动时跟随您的方向';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get resetMapRotation => '重置旋转';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get resetMapRotationTooltip => '重置地图方向为北';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showMapDebugInfo => '显示地图调试信息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get displayZoomLevelBounds => '显示缩放级别和边界';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get fullscreenMode => '全屏模式';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get hideUiFullMapView => '隐藏所有 UI 控件以获得全地图视图';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1008,12 +663,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google 地形图';
|
String get googleTerrain => 'Google 地形图';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => '下载可见区域';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => '正在初始化地图...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => '拖动以定位';
|
String get dragToPosition => '拖动以定位';
|
||||||
|
|
||||||
@@ -1029,49 +678,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => '搜救警报';
|
String get sarAlert => '搜救警报';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageSentToPublicChannel => '消息已发送到公共频道';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get pleaseSelectRoomToSendSar => '请选择一个房间发送搜救标记';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String failedToSendSarMarker(String error) {
|
|
||||||
return '发送搜救标记失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String sarMarkerSentTo(String roomName) {
|
|
||||||
return '搜救标记已发送到 $roomName';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get notConnectedCannotSync => '未连接 - 无法同步消息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String syncedMessageCount(int count) {
|
|
||||||
return '已同步 $count 条消息';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noNewMessages => '没有新消息';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String syncFailed(String error) {
|
|
||||||
return '同步失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get failedToResendMessage => '重新发送消息失败';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get retryingMessage => '正在重试消息...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String retryFailed(String error) {
|
|
||||||
return '重试失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get textCopiedToClipboard => '文本已复制到剪贴板';
|
String get textCopiedToClipboard => '文本已复制到剪贴板';
|
||||||
|
|
||||||
@@ -1081,9 +687,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cannotReplyContactNotFound => '无法回复:未找到联系人';
|
String get cannotReplyContactNotFound => '无法回复:未找到联系人';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => '消息已删除';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => '复制文本';
|
String get copyText => '复制文本';
|
||||||
|
|
||||||
@@ -1118,12 +721,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => '搜救位置';
|
String get sarLocationShare => '搜救位置';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => '位置已共享';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => '联系人已刷新';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => '刚刚';
|
String get justNow => '刚刚';
|
||||||
|
|
||||||
@@ -1231,77 +828,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => '您';
|
String get you => '您';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineVectorMaps => '离线矢量地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineVectorMapsDescription =>
|
|
||||||
'导入和管理离线矢量地图瓦片(MBTiles 格式),以便在没有互联网连接时使用';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importMbtiles => '导入 MBTiles 文件';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importMbtilesNote =>
|
|
||||||
'支持带有矢量瓦片(PBF/MVT 格式)的 MBTiles 文件。Geofabrik 的数据提取非常好用!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noMbtilesFiles => '未找到离线矢量地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mbtilesImportedSuccessfully => 'MBTiles 文件导入成功';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get failedToImportMbtiles => '导入 MBTiles 文件失败';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get deleteMbtilesConfirmTitle => '删除离线地图';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String deleteMbtilesConfirmMessage(String name) {
|
|
||||||
return '您确定要删除“$name”吗?这将永久移除该离线地图。';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get mbtilesDeletedSuccessfully => '离线地图删除成功';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get failedToDeleteMbtiles => '删除离线地图失败';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importExportCachedTiles => '导入/导出缓存的瓦片';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importExportDescription => '在设备之间备份、共享和恢复下载的地图瓦片';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportTilesToFile => '导出瓦片到文件';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importTilesFromFile => '从文件导入瓦片';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get selectExportLocation => '选择导出位置';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get selectImportFile => '选择瓦片存档';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportingTiles => '正在导出瓦片...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importingTiles => '正在导入瓦片...';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String exportSuccess(int count) {
|
|
||||||
return '成功导出 $count 个瓦片';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String importSuccess(int count) {
|
|
||||||
return '成功导入 $count 个存储';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return '导出失败:$error';
|
return '导出失败:$error';
|
||||||
@@ -1312,38 +838,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '导入失败:$error';
|
return '导入失败:$error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportNote => '创建一个压缩存档(.fmtc)文件,可以共享并在其他设备上导入。';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importNote => '从先前导出的存档文件导入地图瓦片。瓦片将与现有缓存合并。';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get noTilesToExport => '没有可导出的瓦片';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String archiveContainsStores(int count) {
|
|
||||||
return '存档包含 $count 个存储';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get vectorTiles => '矢量瓦片';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get schema => '模式';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unknown => '未知';
|
String get unknown => '未知';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => '边界';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => '在线图层';
|
String get onlineLayers => '在线图层';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => '离线图层';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => '位置轨迹';
|
String get locationTrail => '位置轨迹';
|
||||||
|
|
||||||
@@ -1380,47 +880,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => '轨迹控制';
|
String get trailControls => '轨迹控制';
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportTrailToGpx => '导出轨迹到 GPX';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importTrailFromGpx => '从 GPX 导入轨迹';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get trailExportedSuccessfully => '轨迹导出成功!';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get failedToExportTrail => '导出轨迹失败';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String failedToImportTrail(String error) {
|
|
||||||
return '导入轨迹失败:$error';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get importTrail => '导入轨迹';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String importTrailQuestion(int pointCount) {
|
|
||||||
return '导入包含 $pointCount 个点的轨迹吗?\n\n您可以替换当前轨迹,或将其与现有轨迹并排查看。';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get viewAlongside => '并排查看';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get replaceCurrent => '替换当前';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String trailImported(int pointCount) {
|
|
||||||
return '轨迹已导入!($pointCount 个点)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String trailReplaced(int pointCount) {
|
|
||||||
return '轨迹已替换!($pointCount 个点)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactTrails => '联系人轨迹';
|
String get contactTrails => '联系人轨迹';
|
||||||
|
|
||||||
@@ -1672,18 +1131,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => '未找到房间';
|
String get noRoomsFound => '未找到房间';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable => '没有可用的联系人或房间';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => '没有可用的收件人';
|
String get noRecipientsAvailable => '没有可用的收件人';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => '未找到频道';
|
String get noChannelsFound => '未找到频道';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel => '消息将发送到公共频道';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => '新消息';
|
String get newMessage => '新消息';
|
||||||
|
|
||||||
@@ -1855,9 +1308,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => '矩形绘图';
|
String get rectangleDrawing => '矩形绘图';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard => '坐标已复制到剪贴板';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => '手动输入坐标';
|
String get manualCoordinates => '手动输入坐标';
|
||||||
|
|
||||||
@@ -1870,31 +1320,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => '经度';
|
String get longitudeLabel => '经度';
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLatitude => '无效的纬度(-90 到 90)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLongitude => '无效的经度(-180 到 180)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exampleCoordinates => '例如:46.0569, 14.5058';
|
String get exampleCoordinates => '例如:46.0569, 14.5058';
|
||||||
|
|
||||||
@override
|
|
||||||
String get drawingShared => '地图绘制';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get drawingHidden => '绘图已从地图上隐藏';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String alreadyShared(int count) {
|
|
||||||
return '$count 个已共享';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String newDrawingsShared(int count, String plural) {
|
|
||||||
return '已共享 $count 个新绘图';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get shareDrawing => '共享绘图';
|
String get shareDrawing => '共享绘图';
|
||||||
|
|
||||||
@@ -1948,12 +1376,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => '森林道路';
|
String get forestRoads => '森林道路';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => '显示地籍地块';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => '显示森林道路';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'WMS 覆盖层';
|
String get wmsOverlays => 'WMS 覆盖层';
|
||||||
|
|
||||||
@@ -2054,9 +1476,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '删除频道失败:$error';
|
return '删除频道失败:$error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse => '所有频道槽位都已在使用中(最多 39 个自定义频道)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => '创建频道';
|
String get createChannel => '创建频道';
|
||||||
|
|
||||||
@@ -2095,19 +1514,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get wizardConnectingFeature3 => '完全离线工作 - 无需互联网';
|
String get wizardConnectingFeature3 => '完全离线工作 - 无需互联网';
|
||||||
|
|
||||||
@override
|
|
||||||
String get wizardSimpleModeTitle => '简洁模式';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get wizardSimpleModeDescription =>
|
|
||||||
'刚接触 Mesh 网络?启用简洁模式以获得仅包含基本功能的简化界面。';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get wizardSimpleModeFeature1 => '对初学者友好的界面,包含核心功能';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get wizardSimpleModeFeature2 => '随时在设置中切换到高级模式';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wizardChannelTitle => '频道';
|
String get wizardChannelTitle => '频道';
|
||||||
|
|
||||||
@@ -2250,4 +1656,175 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get requestingVoice => '正在请求语音';
|
String get requestingVoice => '正在请求语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get device => '设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get change => '更改';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewDescription =>
|
||||||
|
'此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature1 => '在主消息标签页中发送私聊消息、房间消息和频道消息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature2 => '通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature3 => '通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingTitle => '消息与现场报告';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingDescription => '这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature1 => '通过一个编辑器发送私聊消息、房间消息和频道流量。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature2 => '为常见现场报告创建 SAR 更新和可复用的 SAR 模板。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature3 => '传输语音会话和图像,并在界面中显示进度和空口时间估算。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConnectDeviceTitle => '连接设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConnectDeviceDescription =>
|
||||||
|
'连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSetupBadge => '设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewBadge => '概览';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String wizardConnectedToDevice(String deviceName) {
|
||||||
|
return '已连接到 $deviceName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNoDeviceConnected => '尚未连接设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSkipForNow => '暂时跳过';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardDeviceNameLabel => '设备名称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardDeviceNameHelp => '此名称会广播给其他 MeshCore 用户。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConfigRegionLabel => '配置区域';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConfigRegionHelp =>
|
||||||
|
'使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote1 => '请确认所选预设符合你所在地区的无线电规定。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote2 => '该列表与 MeshCore 配置工具的官方预设源保持一致。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote3 => '在引导设置中默认保持选中 EU/UK (Narrow)。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSaving => '正在保存...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSaveAndContinue => '保存并继续';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardEnterDeviceName => '继续前请输入设备名称。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String wizardDeviceSetupSaved(String deviceName, String presetName) {
|
||||||
|
return '已保存 $deviceName,配置为 $presetName。';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkTitle => '联系人、房间和中继器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkDescription => '联系人标签页会整理你发现的网络以及随着时间学习到的路由。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature1 => '在一个列表中查看团队成员、中继器、房间、频道和待处理广播。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature2 => '当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature3 => '无需离开应用即可创建频道并管理网络目的地。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsTitle => '地图、轨迹与共享几何';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsDescription => '应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature1 => '在地图上跟踪你自己的位置、队友位置和移动轨迹。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature2 => '打开消息中的绘图,内联预览,并在需要时将其从地图上移除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature3 => '使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsTitle => '消息之外的工具';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsDescription => '这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature1 => '打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature2 => '当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactInSensors => '在传感器中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactAddToSensors => '添加到传感器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactSetPath => '设置路径';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactAddedToSensors(String contactName) {
|
||||||
|
return '已将 $contactName 添加到传感器';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactFailedToClearRoute(String error) {
|
||||||
|
return '清除路由失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactRouteCleared => '路由已清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRouteSet(String route) {
|
||||||
|
return '路由已设置:$route';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactFailedToSetRoute(String error) {
|
||||||
|
return '设置路由失败:$error';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2332
lib/l10n/app_pl.arb
Normal file
2332
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_pt.arb
Normal file
2332
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
1228
lib/l10n/app_ru.arb
1228
lib/l10n/app_ru.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_tr.arb
Normal file
2332
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_uk.arb
Normal file
2332
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
1057
lib/l10n/app_zh.arb
1057
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -19,8 +22,10 @@ import 'services/voice_codec_service.dart';
|
|||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
|
import 'services/mesh_map_nodes_service.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
|
import 'screens/discovery_screen.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/welcome_wizard_screen.dart';
|
import 'screens/welcome_wizard_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
@@ -38,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||||
AppThemeMode _themeMode = AppThemeMode.system;
|
AppThemeMode _themeMode = AppThemeMode.system;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _shouldShowPermissionDialog = false;
|
bool _shouldShowPermissionDialog = false;
|
||||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||||
|
String? _pendingNotificationPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -62,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Set up notification tap handler for update notifications
|
// Set up notification tap handler for update notifications
|
||||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||||
|
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||||
|
|
||||||
// Check if we need to request location permissions
|
// Check if we need to request location permissions
|
||||||
await _checkLocationPermissions();
|
await _checkLocationPermissions();
|
||||||
@@ -70,10 +78,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Shows notification if update is available
|
// Shows notification if update is available
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
|
|
||||||
|
// Refresh remote mesh node cache in background when the app starts.
|
||||||
|
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_pendingNotificationPayload != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final payload = _pendingNotificationPayload;
|
||||||
|
_pendingNotificationPayload = null;
|
||||||
|
_handleNotificationTap(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle notification tap
|
/// Handle notification tap
|
||||||
@@ -82,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
debugPrint('[Main] Notification tapped: $payload');
|
debugPrint('[Main] Notification tapped: $payload');
|
||||||
|
|
||||||
|
if (!_wizardCompleted) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle update notification tap
|
// Handle update notification tap
|
||||||
if (payload.startsWith('update:')) {
|
if (payload.startsWith('update:')) {
|
||||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||||
_launchUpdateDownload(downloadUrl);
|
_launchUpdateDownload(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.startsWith('discovery:')) {
|
||||||
|
final navigator = _navigatorKey.currentState;
|
||||||
|
if (navigator == null) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// SAR and message notifications handled by their respective providers
|
// SAR and message notifications handled by their respective providers
|
||||||
}
|
}
|
||||||
@@ -158,7 +194,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
/// Shows notification if update is available
|
/// Shows notification if update is available
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only check for updates on Android
|
// Only check for updates on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -297,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
final materialApp = MaterialApp(
|
final materialApp = MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
key: ValueKey<String?>(
|
key: ValueKey<String?>(
|
||||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||||
),
|
),
|
||||||
@@ -324,7 +361,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
|
|||||||
@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
|
|||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get routeSupportsLegacyRawTransport =>
|
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||||
routeHasPath && routeSignedPathLen >= 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
lib/models/contact_group.dart
Normal file
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class SavedContactGroup {
|
||||||
|
final String id;
|
||||||
|
final String sectionKey;
|
||||||
|
final String label;
|
||||||
|
final String query;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final List<String>? matchPrefixes;
|
||||||
|
final bool isAutoGroup;
|
||||||
|
|
||||||
|
const SavedContactGroup({
|
||||||
|
required this.id,
|
||||||
|
required this.sectionKey,
|
||||||
|
required this.label,
|
||||||
|
required this.query,
|
||||||
|
required this.createdAt,
|
||||||
|
this.matchPrefixes,
|
||||||
|
this.isAutoGroup = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
SavedContactGroup copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sectionKey,
|
||||||
|
String? label,
|
||||||
|
String? query,
|
||||||
|
DateTime? createdAt,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool? isAutoGroup,
|
||||||
|
}) {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sectionKey: sectionKey ?? this.sectionKey,
|
||||||
|
label: label ?? this.label,
|
||||||
|
query: query ?? this.query,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,6 @@ enum ConnectionMode {
|
|||||||
/// Direct BLE connection to MeshCore device (default)
|
/// Direct BLE connection to MeshCore device (default)
|
||||||
ble,
|
ble,
|
||||||
|
|
||||||
/// Act as SSE server - share BLE device with multiple clients
|
|
||||||
sseServer,
|
|
||||||
|
|
||||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
tcp,
|
tcp,
|
||||||
}
|
}
|
||||||
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share Device (Server)';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
}
|
}
|
||||||
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share BLE device with multiple clients over network';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
}
|
}
|
||||||
@@ -66,6 +59,11 @@ class DeviceInfo {
|
|||||||
final int? advLat;
|
final int? advLat;
|
||||||
final int? advLon;
|
final int? advLon;
|
||||||
final bool? manualAddContacts;
|
final bool? manualAddContacts;
|
||||||
|
final bool? autoAddUsers;
|
||||||
|
final bool? autoAddRepeaters;
|
||||||
|
final bool? autoAddRoomServers;
|
||||||
|
final bool? autoAddSensors;
|
||||||
|
final bool? autoAddOverwriteOldest;
|
||||||
final int? radioFreq;
|
final int? radioFreq;
|
||||||
final int? radioBw;
|
final int? radioBw;
|
||||||
final int? radioSf;
|
final int? radioSf;
|
||||||
@@ -113,6 +111,11 @@ class DeviceInfo {
|
|||||||
this.advLat,
|
this.advLat,
|
||||||
this.advLon,
|
this.advLon,
|
||||||
this.manualAddContacts,
|
this.manualAddContacts,
|
||||||
|
this.autoAddUsers,
|
||||||
|
this.autoAddRepeaters,
|
||||||
|
this.autoAddRoomServers,
|
||||||
|
this.autoAddSensors,
|
||||||
|
this.autoAddOverwriteOldest,
|
||||||
this.radioFreq,
|
this.radioFreq,
|
||||||
this.radioBw,
|
this.radioBw,
|
||||||
this.radioSf,
|
this.radioSf,
|
||||||
@@ -239,6 +242,11 @@ class DeviceInfo {
|
|||||||
int? advLat,
|
int? advLat,
|
||||||
int? advLon,
|
int? advLon,
|
||||||
bool? manualAddContacts,
|
bool? manualAddContacts,
|
||||||
|
bool? autoAddUsers,
|
||||||
|
bool? autoAddRepeaters,
|
||||||
|
bool? autoAddRoomServers,
|
||||||
|
bool? autoAddSensors,
|
||||||
|
bool? autoAddOverwriteOldest,
|
||||||
int? radioFreq,
|
int? radioFreq,
|
||||||
int? radioBw,
|
int? radioBw,
|
||||||
int? radioSf,
|
int? radioSf,
|
||||||
@@ -278,6 +286,12 @@ class DeviceInfo {
|
|||||||
advLat: advLat ?? this.advLat,
|
advLat: advLat ?? this.advLat,
|
||||||
advLon: advLon ?? this.advLon,
|
advLon: advLon ?? this.advLon,
|
||||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||||
|
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||||
|
autoAddOverwriteOldest:
|
||||||
|
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||||
radioFreq: radioFreq ?? this.radioFreq,
|
radioFreq: radioFreq ?? this.radioFreq,
|
||||||
radioBw: radioBw ?? this.radioBw,
|
radioBw: radioBw ?? this.radioBw,
|
||||||
radioSf: radioSf ?? this.radioSf,
|
radioSf: radioSf ?? this.radioSf,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
|||||||
final int? senderToReceiptMs;
|
final int? senderToReceiptMs;
|
||||||
final int? estimatedTransmitMs;
|
final int? estimatedTransmitMs;
|
||||||
final int? postTransmitDelayMs;
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
const MessageReceptionDetails({
|
const MessageReceptionDetails({
|
||||||
required this.capturedAt,
|
required this.capturedAt,
|
||||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
|||||||
this.senderToReceiptMs,
|
this.senderToReceiptMs,
|
||||||
this.estimatedTransmitMs,
|
this.estimatedTransmitMs,
|
||||||
this.postTransmitDelayMs,
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
|||||||
'senderToReceiptMs': senderToReceiptMs,
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
'estimatedTransmitMs': estimatedTransmitMs,
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
'postTransmitDelayMs': postTransmitDelayMs,
|
'postTransmitDelayMs': postTransmitDelayMs,
|
||||||
|
'receivedCopies': receivedCopies,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessageReceptionDetails copyWith({
|
||||||
|
DateTime? capturedAt,
|
||||||
|
DateTime? packetLoggedAt,
|
||||||
|
int? rssiDbm,
|
||||||
|
double? snrDb,
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? senderToReceiptMs,
|
||||||
|
int? estimatedTransmitMs,
|
||||||
|
int? postTransmitDelayMs,
|
||||||
|
int? receivedCopies,
|
||||||
|
}) {
|
||||||
|
return MessageReceptionDetails(
|
||||||
|
capturedAt: capturedAt ?? this.capturedAt,
|
||||||
|
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||||
|
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||||
|
snrDb: snrDb ?? this.snrDb,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||||
|
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageReceptionDetails mergeDuplicate({
|
||||||
|
MessageReceptionDetails? existing,
|
||||||
|
MessageReceptionDetails? incoming,
|
||||||
|
}) {
|
||||||
|
final base =
|
||||||
|
existing ??
|
||||||
|
incoming ??
|
||||||
|
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||||
|
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||||
|
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||||
|
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||||
|
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||||
|
senderToReceiptMs:
|
||||||
|
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs:
|
||||||
|
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs:
|
||||||
|
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||||
|
receivedCopies:
|
||||||
|
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
final capturedAtMillis = json['capturedAtMillis'];
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
if (capturedAtMillis is! int) {
|
if (capturedAtMillis is! int) {
|
||||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
|||||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
lib/models/message_route_metadata.dart
Normal file
79
lib/models/message_route_metadata.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'path_selection.dart';
|
||||||
|
|
||||||
|
class MessageRouteMetadata {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final bool routerFallbackAttempted;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
final String? canonicalPath;
|
||||||
|
final int? hopCount;
|
||||||
|
|
||||||
|
const MessageRouteMetadata({
|
||||||
|
required this.mode,
|
||||||
|
required this.routerFallbackAttempted,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
this.canonicalPath,
|
||||||
|
this.hopCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromSelection(
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: selection.mode,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
relayName: selection.relayName,
|
||||||
|
relayKey6: selection.relayKey6,
|
||||||
|
canonicalPath: selection.canonicalPath.isEmpty
|
||||||
|
? null
|
||||||
|
: selection.canonicalPath,
|
||||||
|
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get modeLabel {
|
||||||
|
switch (mode) {
|
||||||
|
case PathSelectionMode.directCurrent:
|
||||||
|
return 'Current direct path';
|
||||||
|
case PathSelectionMode.directHistorical:
|
||||||
|
return 'Rotated direct path';
|
||||||
|
case PathSelectionMode.flood:
|
||||||
|
return 'Flood route';
|
||||||
|
case PathSelectionMode.nearestRouter:
|
||||||
|
final suffix = relayName?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayName'
|
||||||
|
: relayKey6?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayKey6'
|
||||||
|
: '';
|
||||||
|
return 'Nearest router$suffix';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'mode': mode.name,
|
||||||
|
'router_fallback_attempted': routerFallbackAttempted,
|
||||||
|
'relay_name': relayName,
|
||||||
|
'relay_key6': relayKey6,
|
||||||
|
'canonical_path': canonicalPath,
|
||||||
|
'hop_count': hopCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: PathSelectionMode.values.firstWhere(
|
||||||
|
(value) => value.name == json['mode'],
|
||||||
|
orElse: () => PathSelectionMode.directCurrent,
|
||||||
|
),
|
||||||
|
routerFallbackAttempted:
|
||||||
|
json['router_fallback_attempted'] as bool? ?? false,
|
||||||
|
relayName: json['relay_name'] as String?,
|
||||||
|
relayKey6: json['relay_key6'] as String?,
|
||||||
|
canonicalPath: json['canonical_path'] as String?,
|
||||||
|
hopCount: json['hop_count'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
lib/models/path_history.dart
Normal file
197
lib/models/path_history.dart
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
enum PathRecordSource { learned, observed }
|
||||||
|
|
||||||
|
class PathRecord {
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final int hashSize;
|
||||||
|
final PathRecordSource source;
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
final int lastRoundTripTimeMs;
|
||||||
|
final DateTime lastUsedAt;
|
||||||
|
|
||||||
|
const PathRecord({
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.hashSize,
|
||||||
|
required this.source,
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
required this.lastRoundTripTimeMs,
|
||||||
|
required this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get signature =>
|
||||||
|
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
|
||||||
|
double get successRate =>
|
||||||
|
(successCount + 1) / (successCount + failureCount + 2);
|
||||||
|
|
||||||
|
PathRecord copyWith({
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? hopCount,
|
||||||
|
int? hashSize,
|
||||||
|
PathRecordSource? source,
|
||||||
|
int? successCount,
|
||||||
|
int? failureCount,
|
||||||
|
int? lastRoundTripTimeMs,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) {
|
||||||
|
return PathRecord(
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
hopCount: hopCount ?? this.hopCount,
|
||||||
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
source: source ?? this.source,
|
||||||
|
successCount: successCount ?? this.successCount,
|
||||||
|
failureCount: failureCount ?? this.failureCount,
|
||||||
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'path_bytes': pathBytes,
|
||||||
|
'hop_count': hopCount,
|
||||||
|
'hash_size': hashSize,
|
||||||
|
'source': source.name,
|
||||||
|
'success_count': successCount,
|
||||||
|
'failure_count': failureCount,
|
||||||
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
|
'last_used_at': lastUsedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PathRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PathRecord(
|
||||||
|
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
|
.map((value) => value as int)
|
||||||
|
.toList(),
|
||||||
|
hopCount: json['hop_count'] as int? ?? 0,
|
||||||
|
hashSize: json['hash_size'] as int? ?? 1,
|
||||||
|
source: PathRecordSource.values.firstWhere(
|
||||||
|
(value) => value.name == (json['source'] as String? ?? 'learned'),
|
||||||
|
orElse: () => PathRecordSource.learned,
|
||||||
|
),
|
||||||
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
|
lastUsedAt:
|
||||||
|
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FloodPathStats {
|
||||||
|
final int successCount;
|
||||||
|
final int failureCount;
|
||||||
|
final int lastRoundTripTimeMs;
|
||||||
|
final DateTime? lastUsedAt;
|
||||||
|
|
||||||
|
const FloodPathStats({
|
||||||
|
required this.successCount,
|
||||||
|
required this.failureCount,
|
||||||
|
required this.lastRoundTripTimeMs,
|
||||||
|
required this.lastUsedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FloodPathStats.empty()
|
||||||
|
: successCount = 0,
|
||||||
|
failureCount = 0,
|
||||||
|
lastRoundTripTimeMs = 0,
|
||||||
|
lastUsedAt = null;
|
||||||
|
|
||||||
|
FloodPathStats copyWith({
|
||||||
|
int? successCount,
|
||||||
|
int? failureCount,
|
||||||
|
int? lastRoundTripTimeMs,
|
||||||
|
DateTime? lastUsedAt,
|
||||||
|
}) {
|
||||||
|
return FloodPathStats(
|
||||||
|
successCount: successCount ?? this.successCount,
|
||||||
|
failureCount: failureCount ?? this.failureCount,
|
||||||
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success_count': successCount,
|
||||||
|
'failure_count': failureCount,
|
||||||
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
|
'last_used_at': lastUsedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FloodPathStats(
|
||||||
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
|
lastUsedAt: DateTime.tryParse(json['last_used_at'] as String? ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactPathHistory {
|
||||||
|
final String contactPublicKeyHex;
|
||||||
|
final List<PathRecord> directPaths;
|
||||||
|
final FloodPathStats floodStats;
|
||||||
|
final int rotationIndex;
|
||||||
|
|
||||||
|
const ContactPathHistory({
|
||||||
|
required this.contactPublicKeyHex,
|
||||||
|
required this.directPaths,
|
||||||
|
required this.floodStats,
|
||||||
|
required this.rotationIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ContactPathHistory.empty(this.contactPublicKeyHex)
|
||||||
|
: directPaths = const <PathRecord>[],
|
||||||
|
floodStats = const FloodPathStats.empty(),
|
||||||
|
rotationIndex = 0;
|
||||||
|
|
||||||
|
ContactPathHistory copyWith({
|
||||||
|
List<PathRecord>? directPaths,
|
||||||
|
FloodPathStats? floodStats,
|
||||||
|
int? rotationIndex,
|
||||||
|
}) {
|
||||||
|
return ContactPathHistory(
|
||||||
|
contactPublicKeyHex: contactPublicKeyHex,
|
||||||
|
directPaths: directPaths ?? this.directPaths,
|
||||||
|
floodStats: floodStats ?? this.floodStats,
|
||||||
|
rotationIndex: rotationIndex ?? this.rotationIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
|
||||||
|
'flood_stats': floodStats.toJson(),
|
||||||
|
'rotation_index': rotationIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> get observedPaths => directPaths
|
||||||
|
.where((record) => record.source == PathRecordSource.observed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
factory ContactPathHistory.fromJson(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) {
|
||||||
|
return ContactPathHistory(
|
||||||
|
contactPublicKeyHex: contactPublicKeyHex,
|
||||||
|
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(PathRecord.fromJson)
|
||||||
|
.toList(),
|
||||||
|
floodStats: json['flood_stats'] is Map<String, dynamic>
|
||||||
|
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
|
||||||
|
: const FloodPathStats.empty(),
|
||||||
|
rotationIndex: json['rotation_index'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/models/path_selection.dart
Normal file
65
lib/models/path_selection.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
|
||||||
|
|
||||||
|
class PathSelection {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final Uint8List pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final int hashSize;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
|
||||||
|
const PathSelection({
|
||||||
|
required this.mode,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.hashSize,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
});
|
||||||
|
|
||||||
|
PathSelection.flood()
|
||||||
|
: mode = PathSelectionMode.flood,
|
||||||
|
pathBytes = Uint8List(0),
|
||||||
|
hopCount = -1,
|
||||||
|
hashSize = 1,
|
||||||
|
relayName = null,
|
||||||
|
relayKey6 = null;
|
||||||
|
|
||||||
|
bool get usesFlood => mode == PathSelectionMode.flood;
|
||||||
|
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
|
||||||
|
|
||||||
|
String get canonicalPath {
|
||||||
|
if (!hasDirectPath) return '';
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||||
|
hops.add(
|
||||||
|
pathBytes
|
||||||
|
.sublist(index, index + hashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection copyWith({
|
||||||
|
PathSelectionMode? mode,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
int? hopCount,
|
||||||
|
int? hashSize,
|
||||||
|
String? relayName,
|
||||||
|
String? relayKey6,
|
||||||
|
}) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
hopCount: hopCount ?? this.hopCount,
|
||||||
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
relayName: relayName ?? this.relayName,
|
||||||
|
relayKey6: relayKey6 ?? this.relayKey6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/// SSE Server Configuration Model
|
|
||||||
///
|
|
||||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
|
||||||
/// multiple app instances to share a single MeshCore BLE device.
|
|
||||||
class SseServerConfig {
|
|
||||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
|
||||||
final String host;
|
|
||||||
|
|
||||||
/// Server port (default: 12929)
|
|
||||||
final int port;
|
|
||||||
|
|
||||||
/// Whether the SSE server is enabled
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
/// Optional authentication token for basic security
|
|
||||||
/// Clients must include this token in Authorization header
|
|
||||||
final String? authToken;
|
|
||||||
|
|
||||||
const SseServerConfig({
|
|
||||||
this.host = '0.0.0.0',
|
|
||||||
this.port = 12929,
|
|
||||||
this.enabled = false,
|
|
||||||
this.authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Create a copy with updated fields
|
|
||||||
SseServerConfig copyWith({
|
|
||||||
String? host,
|
|
||||||
int? port,
|
|
||||||
bool? enabled,
|
|
||||||
String? authToken,
|
|
||||||
}) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: host ?? this.host,
|
|
||||||
port: port ?? this.port,
|
|
||||||
enabled: enabled ?? this.enabled,
|
|
||||||
authToken: authToken ?? this.authToken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get server URL for clients to connect to
|
|
||||||
String getServerUrl({String? ipAddress}) {
|
|
||||||
final ip = ipAddress ?? host;
|
|
||||||
return 'http://$ip:$port';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to JSON for persistence
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'enabled': enabled,
|
|
||||||
'authToken': authToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: json['host'] as String? ?? '0.0.0.0',
|
|
||||||
port: json['port'] as int? ?? 12929,
|
|
||||||
enabled: json['enabled'] as bool? ?? false,
|
|
||||||
authToken: json['authToken'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
|||||||
/// Provider for managing map drawings
|
/// Provider for managing map drawings
|
||||||
class DrawingProvider with ChangeNotifier {
|
class DrawingProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'map_drawings';
|
static const String _storageKey = 'map_drawings';
|
||||||
|
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||||
|
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
DrawingMode _drawingMode = DrawingMode.none;
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
@@ -20,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Completed drawings
|
// Completed drawings
|
||||||
final List<MapDrawing> _drawings = [];
|
final List<MapDrawing> _drawings = [];
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// In-progress drawing
|
// In-progress drawing
|
||||||
MapDrawing? _currentDrawing;
|
MapDrawing? _currentDrawing;
|
||||||
@@ -51,13 +54,16 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||||
double? get measuredDistance => _measuredDistance;
|
double? get measuredDistance => _measuredDistance;
|
||||||
|
|
||||||
/// Initialize and load saved drawings
|
/// Initialize and load saved drawings
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
await _loadDrawings();
|
||||||
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set drawing mode
|
/// Set drawing mode
|
||||||
@@ -77,15 +83,30 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of received drawings
|
/// Toggle visibility of received drawings
|
||||||
void toggleReceivedDrawings() {
|
Future<void> toggleReceivedDrawings() async {
|
||||||
_showReceivedDrawings = !_showReceivedDrawings;
|
_showReceivedDrawings = !_showReceivedDrawings;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of SAR markers
|
/// Toggle visibility of SAR markers
|
||||||
void toggleSarMarkers() {
|
Future<void> toggleSarMarkers() async {
|
||||||
_showSarMarkers = !_showSarMarkers;
|
_showSarMarkers = !_showSarMarkers;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
|
||||||
|
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
|
||||||
|
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start drawing a line
|
/// Start drawing a line
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Map of ACK tag to timestamp for timeout cleanup
|
/// Map of ACK tag to timestamp for timeout cleanup
|
||||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||||
|
|
||||||
|
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||||
|
Completer<void>? _slotFreedCompleter;
|
||||||
|
|
||||||
/// Track a pending message ID before sending
|
/// Track a pending message ID before sending
|
||||||
///
|
///
|
||||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||||
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
|
|||||||
_messageIdToAckTag.remove(messageId);
|
_messageIdToAckTag.remove(messageId);
|
||||||
}
|
}
|
||||||
_ackTagTimestamps.remove(ackCode);
|
_ackTagTimestamps.remove(ackCode);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove ACK tag mapping by message ID
|
/// Remove ACK tag mapping by message ID
|
||||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
_pendingMessageIds.remove(messageId);
|
_pendingMessageIds.remove(messageId);
|
||||||
final emptyKeys = <String>[];
|
final emptyKeys = <String>[];
|
||||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of pending ACK tags
|
/// Get count of pending ACK tags
|
||||||
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
|
|||||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||||
bool get shouldRateLimit => pendingCount >= 7;
|
bool get shouldRateLimit => pendingCount >= 7;
|
||||||
|
|
||||||
|
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||||
|
///
|
||||||
|
/// Returns immediately if not at the rate limit.
|
||||||
|
Future<void> waitForSlot({
|
||||||
|
Duration timeout = const Duration(milliseconds: 500),
|
||||||
|
}) async {
|
||||||
|
if (!shouldRateLimit) return;
|
||||||
|
_slotFreedCompleter ??= Completer<void>();
|
||||||
|
await _slotFreedCompleter!.future.timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySlotFreed() {
|
||||||
|
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||||
|
_slotFreedCompleter!.complete();
|
||||||
|
}
|
||||||
|
_slotFreedCompleter = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import '../../models/contact.dart';
|
|||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||||
/// - Firmware does NOT automatically retry - app must implement
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
@@ -21,10 +19,10 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
static const int maxRetryAttempts = 4;
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -32,13 +30,12 @@ class MessageRetryManager {
|
|||||||
static const int _defaultLoRaCrcEnabled = 1;
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
static const int _defaultLoRaExplicitHeader = 1;
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get backoff delay for the next retry attempt.
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
int getDelayForAttempt(int attempt) {
|
||||||
int getTimeoutForAttempt(int attempt) {
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
return _retryDelays.last;
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||||
@@ -65,43 +62,8 @@ class MessageRetryManager {
|
|||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message is eligible for retry
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - The message has retryAttempt < 3
|
|
||||||
/// - The contact has a learned path (contact.hasPath == true)
|
|
||||||
/// - The message hasn't used flood fallback yet
|
|
||||||
///
|
|
||||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttempts;
|
||||||
if (message.usedFloodFallback) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never retry beyond 3 attempts
|
|
||||||
if (message.retryAttempt >= 3) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only retry if contact has a learned path
|
|
||||||
// If no path, the device uses flood mode automatically - retrying won't help
|
|
||||||
return contact.routeHasPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if should fall back to flood mode
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
|
||||||
/// - Contact HAS a learned path (so direct mode was used)
|
|
||||||
/// - Hasn't already used flood fallback
|
|
||||||
///
|
|
||||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
|
||||||
/// Contacts without paths already use flood mode automatically.
|
|
||||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
|
||||||
return message.retryAttempt >= 3 &&
|
|
||||||
contact.routeHasPath &&
|
|
||||||
!message.usedFloodFallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!requester.routeSupportsLegacyRawTransport) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPath.isEmpty) {
|
if (requester.outPath.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
try {
|
try {
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
|
|||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
|
||||||
class MapProvider with ChangeNotifier {
|
class MapProvider with ChangeNotifier {
|
||||||
|
MapProvider() {
|
||||||
|
unawaited(_loadInitialState());
|
||||||
|
}
|
||||||
|
|
||||||
LatLng? _targetLocation;
|
LatLng? _targetLocation;
|
||||||
double? _targetZoom;
|
double? _targetZoom;
|
||||||
bool _shouldAnimate = false;
|
bool _shouldAnimate = false;
|
||||||
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail toggles
|
// Contact trail toggles
|
||||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
bool _showAllContactTrails = true; // Default to showing all contact trails
|
||||||
|
bool _hideRepeatersOnMap = false;
|
||||||
|
|
||||||
// Imported trail (from GPX)
|
// Imported trail (from GPX)
|
||||||
LocationTrail? _importedTrail;
|
LocationTrail? _importedTrail;
|
||||||
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Contact trail getters
|
// Contact trail getters
|
||||||
bool get showAllContactTrails => _showAllContactTrails;
|
bool get showAllContactTrails => _showAllContactTrails;
|
||||||
|
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||||
|
|
||||||
// Imported trail getters
|
// Imported trail getters
|
||||||
LocationTrail? get importedTrail => _importedTrail;
|
LocationTrail? get importedTrail => _importedTrail;
|
||||||
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialState() async {
|
||||||
|
await Future.wait([loadOverlayState(), loadTrailSettings()]);
|
||||||
|
await loadRepeaterVisibilitySettings();
|
||||||
|
}
|
||||||
|
|
||||||
/// Save overlay state to SharedPreferences
|
/// Save overlay state to SharedPreferences
|
||||||
Future<void> _saveOverlayState() async {
|
Future<void> _saveOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
|
|||||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||||
|
if (_hideRepeatersOnMap == hide) return;
|
||||||
|
_hideRepeatersOnMap = hide;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadRepeaterVisibilitySettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set imported trail (from GPX import)
|
/// Set imported trail (from GPX import)
|
||||||
void setImportedTrail(LocationTrail trail) {
|
void setImportedTrail(LocationTrail trail) {
|
||||||
_importedTrail = trail;
|
_importedTrail = trail;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
|
|
||||||
@@ -15,6 +16,19 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
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';
|
||||||
|
static const String _metricLabelKey = 'sensor_metric_labels';
|
||||||
|
static const String _metricOrderKey = 'sensor_metric_order';
|
||||||
|
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
||||||
|
static const List<int> supportedAutoRefreshIntervals = <int>[
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
15,
|
||||||
|
30,
|
||||||
|
60,
|
||||||
|
360,
|
||||||
|
720,
|
||||||
|
1440,
|
||||||
|
];
|
||||||
static const Set<String> _defaultVisibleFields = <String>{
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
'voltage',
|
'voltage',
|
||||||
'battery',
|
'battery',
|
||||||
@@ -23,6 +37,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
'pressure',
|
'pressure',
|
||||||
'gps',
|
'gps',
|
||||||
};
|
};
|
||||||
|
static const List<String> _defaultMetricOrder = <String>[
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'gps',
|
||||||
|
];
|
||||||
|
|
||||||
final List<String> _watchedSensorKeys = <String>[];
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
final Map<String, SensorRefreshState> _refreshStates =
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
@@ -32,8 +54,15 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
<String, Set<String>>{};
|
<String, Set<String>>{};
|
||||||
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
<String, Map<String, int>>{};
|
<String, Map<String, int>>{};
|
||||||
|
final Map<String, Map<String, String>> _metricLabelsBySensor =
|
||||||
|
<String, Map<String, String>>{};
|
||||||
|
final Map<String, List<String>> _metricOrderBySensor =
|
||||||
|
<String, List<String>>{};
|
||||||
|
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
|
||||||
|
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _isRefreshingAll = false;
|
bool _isRefreshingAll = false;
|
||||||
|
bool _isRunningAutoRefreshTick = false;
|
||||||
|
|
||||||
SensorsProvider() {
|
SensorsProvider() {
|
||||||
unawaited(_loadWatchedSensors());
|
unawaited(_loadWatchedSensors());
|
||||||
@@ -52,11 +81,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||||
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
||||||
|
final storedLabelsJson = prefs.getString(_metricLabelKey);
|
||||||
|
final storedOrderJson = prefs.getString(_metricOrderKey);
|
||||||
|
final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey);
|
||||||
_watchedSensorKeys
|
_watchedSensorKeys
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(stored);
|
..addAll(stored);
|
||||||
_visibleFieldsBySensor.clear();
|
_visibleFieldsBySensor.clear();
|
||||||
_fieldSpansBySensor.clear();
|
_fieldSpansBySensor.clear();
|
||||||
|
_metricLabelsBySensor.clear();
|
||||||
|
_metricOrderBySensor.clear();
|
||||||
|
_autoRefreshMinutesBySensor.clear();
|
||||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
@@ -72,12 +107,49 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
.map((key, value) => MapEntry(key, value as int));
|
.map((key, value) => MapEntry(key, value as int));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedLabelsJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricLabelsBySensor[entry.key] =
|
||||||
|
(entry.value as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedOrderJson != null && storedOrderJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedOrderJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricOrderBySensor[entry.key] = (entry.value as List<dynamic>)
|
||||||
|
.cast<String>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) {
|
||||||
|
final decoded =
|
||||||
|
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final minutes = _normalizeAutoRefreshMinutes(
|
||||||
|
(entry.value as num).toInt(),
|
||||||
|
);
|
||||||
|
if (minutes > 0) {
|
||||||
|
_autoRefreshMinutesBySensor[entry.key] = minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_autoRefreshMinutesBySensor.removeWhere(
|
||||||
|
(key, _) => !_watchedSensorKeys.contains(key),
|
||||||
|
);
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
_visibleFieldsBySensor.putIfAbsent(
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
key,
|
key,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
|
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
|
||||||
|
_metricOrderBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading watched sensors: $e');
|
debugPrint('Error loading watched sensors: $e');
|
||||||
@@ -118,10 +190,56 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricLabels() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric labels: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricOrder() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric order: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistAutoRefreshMinutes() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_autoRefreshMinutesKey,
|
||||||
|
jsonEncode(_autoRefreshMinutesBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor auto refresh minutes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Set<String> effectiveVisibleFieldsFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final storedVisibleFields =
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
|
||||||
|
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
|
||||||
|
return Set<String>.unmodifiable(storedVisibleFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set<String>.unmodifiable(<String>{
|
||||||
|
...storedVisibleFields,
|
||||||
|
...availableFieldKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bool showsField(String publicKeyHex, String fieldKey) =>
|
bool showsField(String publicKeyHex, String fieldKey) =>
|
||||||
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||||
|
|
||||||
@@ -131,6 +249,104 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
return span == 2 ? 2 : 1;
|
return span == 2 ? 2 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> metricOrderFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final available = availableFieldKeys.toList();
|
||||||
|
final availableSet = available.toSet();
|
||||||
|
final ordered = <String>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
final stored =
|
||||||
|
_metricOrderBySensor[publicKeyHex] ??
|
||||||
|
List<String>.from(_defaultMetricOrder);
|
||||||
|
|
||||||
|
for (final fieldKey in stored) {
|
||||||
|
if (availableSet.contains(fieldKey) && seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final fieldKey in available) {
|
||||||
|
if (seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List<String>.unmodifiable(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> labelOverridesFor(String publicKeyHex) =>
|
||||||
|
Map<String, String>.unmodifiable(
|
||||||
|
_metricLabelsBySensor[publicKeyHex] ?? const <String, String>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
String? labelOverrideFor(String publicKeyHex, String fieldKey) =>
|
||||||
|
_metricLabelsBySensor[publicKeyHex]?[fieldKey];
|
||||||
|
|
||||||
|
int autoRefreshMinutesFor(String publicKeyHex) =>
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
||||||
|
|
||||||
|
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
||||||
|
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
|
||||||
|
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
||||||
|
if (currentMinutes == normalizedMinutes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMinutes == 0) {
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
} else {
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
|
||||||
|
}
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _normalizeAutoRefreshMinutes(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportedAutoRefreshIntervals.contains(minutes)) {
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final positiveIntervals = supportedAutoRefreshIntervals.where(
|
||||||
|
(value) => value > 0,
|
||||||
|
);
|
||||||
|
var bestMatch = positiveIntervals.first;
|
||||||
|
var bestDistance = (minutes - bestMatch).abs();
|
||||||
|
|
||||||
|
for (final interval in positiveIntervals.skip(1)) {
|
||||||
|
final distance = (minutes - interval).abs();
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestMatch = interval;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = <String>[];
|
||||||
|
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
final minutes = autoRefreshMinutesFor(key);
|
||||||
|
if (minutes <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[key];
|
||||||
|
if (lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
|
||||||
|
dueKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<String>.unmodifiable(dueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleMetric(
|
Future<void> toggleMetric(
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
String fieldKey,
|
String fieldKey,
|
||||||
@@ -140,8 +356,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
|
final metricOrder = _metricOrderBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
|
var shouldPersistOrder = false;
|
||||||
if (visible) {
|
if (visible) {
|
||||||
visibleFields.add(fieldKey);
|
visibleFields.add(fieldKey);
|
||||||
|
if (!metricOrder.contains(fieldKey)) {
|
||||||
|
metricOrder.add(fieldKey);
|
||||||
|
shouldPersistOrder = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
return;
|
return;
|
||||||
@@ -149,6 +374,9 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
visibleFields.remove(fieldKey);
|
visibleFields.remove(fieldKey);
|
||||||
}
|
}
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
|
if (shouldPersistOrder) {
|
||||||
|
await _persistMetricOrder();
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +394,62 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setMetricLabel(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
String? label,
|
||||||
|
) async {
|
||||||
|
final sensorLabels = _metricLabelsBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => <String, String>{},
|
||||||
|
);
|
||||||
|
final trimmed = label?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) {
|
||||||
|
sensorLabels.remove(fieldKey);
|
||||||
|
} else {
|
||||||
|
sensorLabels[fieldKey] = trimmed;
|
||||||
|
}
|
||||||
|
if (sensorLabels.isEmpty) {
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
}
|
||||||
|
await _persistMetricLabels();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> moveMetric(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required List<String> availableFieldKeys,
|
||||||
|
required int oldIndex,
|
||||||
|
required int newIndex,
|
||||||
|
}) async {
|
||||||
|
if (oldIndex < 0 ||
|
||||||
|
newIndex < 0 ||
|
||||||
|
oldIndex >= availableFieldKeys.length ||
|
||||||
|
newIndex >= availableFieldKeys.length ||
|
||||||
|
oldIndex == newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reordered = List<String>.from(
|
||||||
|
metricOrderFor(publicKeyHex, availableFieldKeys),
|
||||||
|
);
|
||||||
|
final fieldKey = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, fieldKey);
|
||||||
|
|
||||||
|
final storedTail =
|
||||||
|
(_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where(
|
||||||
|
(key) => !reordered.contains(key),
|
||||||
|
);
|
||||||
|
_metricOrderBySensor[publicKeyHex] = <String>[...reordered, ...storedTail];
|
||||||
|
await _persistMetricOrder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool isWatched(String publicKeyHex) =>
|
bool isWatched(String publicKeyHex) =>
|
||||||
_watchedSensorKeys.contains(publicKeyHex);
|
_watchedSensorKeys.contains(publicKeyHex);
|
||||||
|
|
||||||
Future<void> addSensor(Contact contact) async {
|
Future<void> addSensor(Contact contact) async {
|
||||||
if (!contact.isChat && !contact.isRepeater) {
|
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||||
@@ -178,25 +457,69 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
|
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
|
||||||
_defaultVisibleFields,
|
|
||||||
);
|
|
||||||
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
|
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||||
|
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
initialVisibleFields,
|
||||||
|
);
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _shouldAutoIncludeAvailableFields(
|
||||||
|
String publicKeyHex,
|
||||||
|
Set<String> storedVisibleFields,
|
||||||
|
) {
|
||||||
|
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedOrder = _metricOrderBySensor[publicKeyHex];
|
||||||
|
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedLabels = _metricLabelsBySensor[publicKeyHex];
|
||||||
|
if (storedLabels != null && storedLabels.isNotEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedSpans = _fieldSpansBySensor[publicKeyHex];
|
||||||
|
if (storedSpans != null &&
|
||||||
|
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _initialVisibleFieldsForContact(Contact contact) {
|
||||||
|
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
_visibleFieldsBySensor.remove(publicKeyHex);
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
_fieldSpansBySensor.remove(publicKeyHex);
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
_metricOrderBySensor.remove(publicKeyHex);
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +527,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final candidates = <Contact>[
|
final candidates = <Contact>[
|
||||||
...contactsProvider.chatContacts,
|
...contactsProvider.chatContacts,
|
||||||
...contactsProvider.repeaters,
|
...contactsProvider.repeaters,
|
||||||
|
...contactsProvider.sensorContacts,
|
||||||
];
|
];
|
||||||
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||||
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
@@ -239,7 +563,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
required String publicKeyHex,
|
required String publicKeyHex,
|
||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
required ConnectionProvider connectionProvider,
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? requestedAt,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
|
||||||
|
|
||||||
Contact? contact;
|
Contact? contact;
|
||||||
for (final entry in contactsProvider.contacts) {
|
for (final entry in contactsProvider.contacts) {
|
||||||
if (entry.publicKeyHex == publicKeyHex) {
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
@@ -266,6 +597,38 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshDueSensors({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected ||
|
||||||
|
_isRefreshingAll ||
|
||||||
|
_isRunningAutoRefreshTick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
|
||||||
|
if (dueKeys.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunningAutoRefreshTick = true;
|
||||||
|
try {
|
||||||
|
for (final key in dueKeys) {
|
||||||
|
await refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
requestedAt: refreshTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isRunningAutoRefreshTick = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clearExpiredRefreshStates({DateTime? now}) {
|
void clearExpiredRefreshStates({DateTime? now}) {
|
||||||
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||||
final keysToClear = <String>[];
|
final keysToClear = <String>[];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final decodedPcm = await _codec.decodePackets(
|
||||||
|
session.packets,
|
||||||
|
session.mode,
|
||||||
|
);
|
||||||
|
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||||
_playingSessionId = sessionId;
|
_playingSessionId = sessionId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _player.play(pcm);
|
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
|||||||
386
lib/screens/add_contact_screen.dart
Normal file
386
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
|
||||||
|
class AddContactScreen extends StatefulWidget {
|
||||||
|
const AddContactScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactScreenState extends State<AddContactScreen> {
|
||||||
|
final TextEditingController _advertController = TextEditingController();
|
||||||
|
bool _isImporting = false;
|
||||||
|
bool _importSucceeded = false;
|
||||||
|
String? _validationError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadClipboardIfPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_advertController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadClipboardIfPresent() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text?.trim();
|
||||||
|
if (!mounted || text == null || text.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_normalizeAdvertText(text) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_advertController.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizeAdvertText(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = trimmed;
|
||||||
|
if (normalized.startsWith('meshcore://')) {
|
||||||
|
normalized = normalized.substring('meshcore://'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
|
||||||
|
if (!isHex || normalized.length.isOdd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final bytes = <int>[];
|
||||||
|
for (var i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteFromClipboard() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text;
|
||||||
|
if (text == null || text.trim().isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_advertController.text = text.trim();
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importContact() async {
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
if (normalized == null) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertBytes = _hexToBytes(normalized);
|
||||||
|
if (advertBytes.length < 98) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Advert is too short. Expected exported contact data.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
await connectionProvider.importContactAdvert(advertBytes);
|
||||||
|
final importError = connectionProvider.error;
|
||||||
|
|
||||||
|
if (importError == null) {
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importError != null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(importError)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Contact imported')));
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Import Contact')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_add_alt_1_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Import a shared contact advert',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: const [
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.link_outlined,
|
||||||
|
label: 'Accepts share links',
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.code_outlined,
|
||||||
|
label: 'Supports raw hex',
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.content_paste_go_outlined,
|
||||||
|
label: 'Clipboard-friendly',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advert payload',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'You can paste the full share link or only the hex payload.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _advertController,
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: 9,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_validationError != null || _importSucceeded) {
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Contact advert',
|
||||||
|
hintText: 'Paste a share link or hex advert',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _validationError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
normalized == null
|
||||||
|
? 'No valid advert detected yet'
|
||||||
|
: 'Advert size: ${normalized.length ~/ 2} bytes',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||||
|
icon: const Icon(Icons.content_paste_go_outlined),
|
||||||
|
label: const Text('Paste'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: (_isImporting || _importSucceeded)
|
||||||
|
? null
|
||||||
|
: _importContact,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
icon: _isImporting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: _importSucceeded
|
||||||
|
? const Icon(Icons.check_circle_outline)
|
||||||
|
: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
|
label: Text(
|
||||||
|
_isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: _importSucceeded
|
||||||
|
? 'Imported'
|
||||||
|
: 'Import Contact',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_importSucceeded)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Contact imported. Paste another advert or edit the current one to import again.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportHintChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _ImportHintChip({required this.icon, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
701
lib/screens/discovery_screen.dart
Normal file
701
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||||
|
|
||||||
|
enum _DiscoveryMenuAction { repeaters, sensors }
|
||||||
|
|
||||||
|
class DiscoveryScreen extends StatefulWidget {
|
||||||
|
const DiscoveryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||||
|
static const int _repeaterAdvertType = 2;
|
||||||
|
static const int _sensorAdvertType = 4;
|
||||||
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||||
|
bool _isResolvingAll = false;
|
||||||
|
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
||||||
|
switch (action) {
|
||||||
|
case _DiscoveryMenuAction.repeaters:
|
||||||
|
await _discoverNodeType(_repeaterAdvertType);
|
||||||
|
break;
|
||||||
|
case _DiscoveryMenuAction.sensors:
|
||||||
|
await _discoverNodeType(_sensorAdvertType);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearAllDiscoveries() async {
|
||||||
|
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
|
||||||
|
if (pendingCount == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear discoveries'),
|
||||||
|
content: Text(
|
||||||
|
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear all'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.read<ContactsProvider>().clearPendingAdverts();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Cleared pending discoveries.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _discoverNodeType(int advertType) async {
|
||||||
|
if (_runningDiscoveryTypes.contains(advertType)) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_runningDiscoveryTypes.add(advertType);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ConnectionProvider>().discoverNodeType(
|
||||||
|
advertType: advertType,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final label = switch (advertType) {
|
||||||
|
_repeaterAdvertType => 'Repeater discovery sent',
|
||||||
|
_sensorAdvertType => 'Sensor discovery sent',
|
||||||
|
_ => 'Discovery sent',
|
||||||
|
};
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(label)));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_runningDiscoveryTypes.remove(advertType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAdvert(PendingAdvert advert) async {
|
||||||
|
final keyHex = advert.publicKeyHex;
|
||||||
|
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.add(keyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
|
final canAddDirectly = _canAddPendingAdvertDirectly(advert);
|
||||||
|
if (canAddDirectly) {
|
||||||
|
final added = await _addPendingAdvertToRadio(
|
||||||
|
advert,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await connectionProvider.getContact(advert.publicKey);
|
||||||
|
if (connectionProvider.error == 'Not found') {
|
||||||
|
connectionProvider.clearError();
|
||||||
|
final added = await _addPendingAdvertToRadio(
|
||||||
|
advert,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
);
|
||||||
|
if (!added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||||
|
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.remove(keyHex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
|
||||||
|
if (_isResolvingAll || adverts.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final advert in adverts) {
|
||||||
|
if (!mounted) break;
|
||||||
|
await _resolveAdvert(advert);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final diff = DateTime.now().difference(when);
|
||||||
|
if (diff.inMinutes < 1) return l10n.justNow;
|
||||||
|
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||||
|
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||||
|
return l10n.daysAgo(diff.inDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact _contactFromPendingAdvert(PendingAdvert advert) {
|
||||||
|
final lastAdvert =
|
||||||
|
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
|
||||||
|
final advName = advert.advName?.trim().isNotEmpty == true
|
||||||
|
? advert.advName!.trim()
|
||||||
|
: advert.shortDisplayKey;
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(advert.publicKey),
|
||||||
|
type: ContactType.fromValue(advert.typeValue ?? 0),
|
||||||
|
flags: advert.flags ?? 0,
|
||||||
|
outPathLen: advert.signedEncodedPathLen ?? -1,
|
||||||
|
outPath: advert.paddedPathBytes == null
|
||||||
|
? Uint8List(64)
|
||||||
|
: Uint8List.fromList(advert.paddedPathBytes!),
|
||||||
|
advName: advName,
|
||||||
|
lastAdvert: lastAdvert,
|
||||||
|
advLat: advert.advLat ?? 0,
|
||||||
|
advLon: advert.advLon ?? 0,
|
||||||
|
lastMod: lastAdvert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _canAddPendingAdvertDirectly(PendingAdvert advert) {
|
||||||
|
return advert.publicKey.length == 32 &&
|
||||||
|
advert.typeValue != null &&
|
||||||
|
advert.typeValue != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _addPendingAdvertToRadio(
|
||||||
|
PendingAdvert advert, {
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
}) async {
|
||||||
|
final contact = _contactFromPendingAdvert(advert);
|
||||||
|
await connectionProvider.addOrUpdateContact(contact);
|
||||||
|
|
||||||
|
final addError = connectionProvider.error;
|
||||||
|
if (addError != null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add contact: $addError')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsProvider.addOrUpdateContact(
|
||||||
|
contact,
|
||||||
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayNameForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
final advertisedName = advert.advName?.trim();
|
||||||
|
if (advertisedName != null && advertisedName.isNotEmpty) {
|
||||||
|
return advertisedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? existingMatch;
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.publicKeyHex == advert.publicKeyHex) {
|
||||||
|
existingMatch = contact;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
|
||||||
|
return existingMatch.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode? cachedMatch;
|
||||||
|
for (final node in cachedNodes) {
|
||||||
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
|
cachedMatch = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
|
||||||
|
return cachedMatch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advert.shortDisplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconForAdvert(PendingAdvert advert) {
|
||||||
|
return switch (advert.typeValue) {
|
||||||
|
_repeaterAdvertType => Icons.router_outlined,
|
||||||
|
_sensorAdvertType => Icons.sensors_outlined,
|
||||||
|
_ => Icons.campaign_outlined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _typeLabelForAdvert(PendingAdvert advert) {
|
||||||
|
return switch (advert.typeValue) {
|
||||||
|
_repeaterAdvertType => 'Repeater',
|
||||||
|
_sensorAdvertType => 'Sensor',
|
||||||
|
3 => 'Room',
|
||||||
|
1 => 'Chat',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _resolvedTypeLabelForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
final directType = _typeLabelForAdvert(advert);
|
||||||
|
if (directType != null) {
|
||||||
|
return directType;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final node in cachedNodes) {
|
||||||
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
|
return switch (node.type) {
|
||||||
|
1 => 'Repeater',
|
||||||
|
4 => 'Sensor',
|
||||||
|
3 => 'Room',
|
||||||
|
2 => 'Chat',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvertTitle(
|
||||||
|
BuildContext context, {
|
||||||
|
required String displayName,
|
||||||
|
String? subtitle,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Discovery'),
|
||||||
|
actions: [
|
||||||
|
Consumer<ConnectionProvider>(
|
||||||
|
builder: (context, connectionProvider, child) {
|
||||||
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_repeaterAdvertType,
|
||||||
|
);
|
||||||
|
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||||
|
_sensorAdvertType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return PopupMenuButton<_DiscoveryMenuAction>(
|
||||||
|
tooltip: 'Discovery tools',
|
||||||
|
onSelected: _handleMenuAction,
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem<_DiscoveryMenuAction>(
|
||||||
|
value: _DiscoveryMenuAction.repeaters,
|
||||||
|
enabled: isConnected && !repeatersBusy,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
repeatersBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.router_outlined),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Discover repeaters'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem<_DiscoveryMenuAction>(
|
||||||
|
value: _DiscoveryMenuAction.sensors,
|
||||||
|
enabled: isConnected && !sensorsBusy,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
sensorsBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sensors_outlined),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('Discover sensors'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: FutureBuilder<List<MeshMapNode>>(
|
||||||
|
future: _cachedNodesFuture,
|
||||||
|
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
|
||||||
|
builder: (context, contactsProvider, connectionProvider, child) {
|
||||||
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Pending discoveries (${pendingAdverts.length})',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Resolve entries manually so they do not auto-populate contacts.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
isConnected &&
|
||||||
|
pendingAdverts.isNotEmpty &&
|
||||||
|
!_isResolvingAll
|
||||||
|
? () => _resolveAll(pendingAdverts)
|
||||||
|
: null,
|
||||||
|
icon: _isResolvingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.download_for_offline_outlined,
|
||||||
|
),
|
||||||
|
label: const Text('Resolve all'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: pendingAdverts.isNotEmpty
|
||||||
|
? _clearAllDiscoveries
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.clear_all_rounded),
|
||||||
|
label: const Text('Clear all'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (pendingAdverts.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).disabledColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No pending discoveries',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Unknown adverts will appear here until you choose to resolve them.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...pendingAdverts.map((advert) {
|
||||||
|
final isResolving = _resolvingAdvertKeys.contains(
|
||||||
|
advert.publicKeyHex,
|
||||||
|
);
|
||||||
|
final displayName = _displayNameForAdvert(
|
||||||
|
advert,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||||
|
advert,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
final downMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.rxRssiDbm,
|
||||||
|
snrDb: advert.rxSnr,
|
||||||
|
);
|
||||||
|
final upMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.repeaterLastRssi,
|
||||||
|
snrDb: advert.repeaterLastSnr,
|
||||||
|
);
|
||||||
|
final detailLines = <String>[
|
||||||
|
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||||
|
];
|
||||||
|
final summaryParts = <String>[];
|
||||||
|
final battery = advert.repeaterBatteryPercent;
|
||||||
|
if (battery != null) {
|
||||||
|
summaryParts.add('Battery ${battery.round()}%');
|
||||||
|
}
|
||||||
|
if (advert.repeaterQueueLen != null) {
|
||||||
|
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||||
|
}
|
||||||
|
if (summaryParts.isNotEmpty) {
|
||||||
|
detailLines.add(summaryParts.join(' • '));
|
||||||
|
}
|
||||||
|
detailLines.add(
|
||||||
|
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(child: Icon(_iconForAdvert(advert))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildAdvertTitle(
|
||||||
|
context,
|
||||||
|
displayName: displayName,
|
||||||
|
subtitle: typeLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (downMetric != null || upMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSignalSummary(
|
||||||
|
context,
|
||||||
|
downMetric: downMetric,
|
||||||
|
upMetric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
isResolving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
|
tooltip: 'Resolve contact',
|
||||||
|
onPressed: isConnected
|
||||||
|
? () => _resolveAdvert(advert)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
detailLines.join('\n'),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignalSummary(
|
||||||
|
BuildContext context, {
|
||||||
|
SignalMetric? downMetric,
|
||||||
|
SignalMetric? upMetric,
|
||||||
|
}) {
|
||||||
|
if (downMetric == null && upMetric == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (downMetric != null)
|
||||||
|
_buildDirectionalSignal(
|
||||||
|
context,
|
||||||
|
icon: Icons.south_west_rounded,
|
||||||
|
metric: downMetric,
|
||||||
|
),
|
||||||
|
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
|
||||||
|
if (upMetric != null)
|
||||||
|
_buildDirectionalSignal(
|
||||||
|
context,
|
||||||
|
icon: Icons.north_east_rounded,
|
||||||
|
metric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDirectionalSignal(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required SignalMetric metric,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 11,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
_buildMiniSignalBars(context, metric),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
metric.valueLabel,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
|
||||||
|
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < 3; index++) ...[
|
||||||
|
if (index > 0) const SizedBox(width: 2),
|
||||||
|
Container(
|
||||||
|
width: 3,
|
||||||
|
height: 6.0 + (index * 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: index < metric.activeBars ? metric.color : inactive,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,17 +12,22 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
|
import 'discovery_screen.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
|
import 'repeaters_map_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';
|
||||||
|
import 'live_traffic_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
@@ -55,6 +60,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
|
bool _isDeveloperModeEnabled = false;
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
@@ -90,6 +96,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
_loadDeveloperModePreference();
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
if (widget.shouldShowPermissionDialog) {
|
if (widget.shouldShowPermissionDialog) {
|
||||||
@@ -192,7 +200,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
break;
|
break;
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
context.read<ContactsProvider>().markAllAsViewed();
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
@@ -213,10 +220,17 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openLiveTraffic(ConnectionProvider provider) {
|
||||||
|
openLiveTrafficScreen(context, provider);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
_lifecycleState = state;
|
_lifecycleState = state;
|
||||||
_syncFastLocationUiState();
|
_syncFastLocationUiState();
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadRxTxPreference() async {
|
Future<void> _loadRxTxPreference() async {
|
||||||
@@ -228,6 +242,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperModePreference() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
@@ -378,10 +400,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
required int count,
|
required int count,
|
||||||
required bool isActive,
|
required bool isActive,
|
||||||
required Color activeColor,
|
required Color activeColor,
|
||||||
|
bool compact = false,
|
||||||
}) {
|
}) {
|
||||||
final color = isActive ? activeColor : Colors.grey;
|
final color = isActive ? activeColor : Colors.grey;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 7 : 8,
|
||||||
|
vertical: compact ? 4 : 5,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color.withValues(alpha: 0.12),
|
color: color.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -390,15 +416,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 7,
|
width: compact ? 6 : 7,
|
||||||
height: 7,
|
height: compact ? 6 : 7,
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
SizedBox(width: compact ? 5 : 6),
|
||||||
Text(
|
Text(
|
||||||
'$label:$count',
|
'$label:$count',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: compact ? 10 : 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: color,
|
color: color,
|
||||||
),
|
),
|
||||||
@@ -598,56 +624,160 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) {
|
||||||
PopupMenuItem(
|
final items = <PopupMenuEntry<void>>[];
|
||||||
child: Row(
|
|
||||||
children: [
|
if (_isDeveloperModeEnabled) {
|
||||||
const Icon(Icons.radar),
|
items.add(
|
||||||
const SizedBox(width: 8),
|
PopupMenuItem(
|
||||||
const Text('Spectrum Scan'),
|
child: Row(
|
||||||
],
|
children: [
|
||||||
),
|
const Icon(Icons.radar),
|
||||||
onTap: () {
|
const SizedBox(width: 8),
|
||||||
final navigator = Navigator.of(context);
|
const Text('Spectrum Scan'),
|
||||||
Future.delayed(Duration.zero, () {
|
],
|
||||||
if (!mounted) return;
|
),
|
||||||
navigator.push(
|
onTap: () {
|
||||||
MaterialPageRoute(
|
final navigator = Navigator.of(context);
|
||||||
builder: (context) => const SpectrumScanScreen(),
|
Future.delayed(Duration.zero, () {
|
||||||
),
|
if (!mounted) return;
|
||||||
);
|
navigator.push(
|
||||||
});
|
MaterialPageRoute(
|
||||||
},
|
builder: (context) =>
|
||||||
),
|
const SpectrumScanScreen(),
|
||||||
PopupMenuItem(
|
),
|
||||||
child: Row(
|
);
|
||||||
children: [
|
});
|
||||||
const Icon(Icons.settings),
|
},
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(AppLocalizations.of(context)!.settings),
|
);
|
||||||
],
|
}
|
||||||
),
|
|
||||||
onTap: () {
|
items.add(
|
||||||
// Capture context-dependent objects before async gap
|
PopupMenuItem(
|
||||||
final navigator = Navigator.of(context);
|
child: const Row(
|
||||||
Future.delayed(Duration.zero, () async {
|
children: [
|
||||||
if (!mounted) return;
|
Icon(Icons.radar_outlined),
|
||||||
await navigator.push(
|
SizedBox(width: 8),
|
||||||
MaterialPageRoute(
|
Text('Live Traffic'),
|
||||||
builder: (context) => SettingsScreen(
|
],
|
||||||
onThemeChanged: widget.onThemeChanged,
|
),
|
||||||
onLocaleChanged: widget.onLocaleChanged,
|
onTap: () {
|
||||||
currentTheme: widget.currentTheme,
|
final navigator = Navigator.of(context);
|
||||||
currentLocale: widget.currentLocale,
|
final provider = context.read<ConnectionProvider>();
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => LiveTrafficScreen.fromProvider(
|
||||||
|
provider,
|
||||||
|
openPacketLogs: () {
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PacketLogScreen(
|
||||||
|
bleService: provider.bleService,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.router_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Repeaters Map'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const RepeatersMapScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final pendingCount =
|
||||||
|
contactsProvider.pendingAdverts.length;
|
||||||
|
return Text(
|
||||||
|
pendingCount > 0
|
||||||
|
? 'Discovery ($pendingCount)'
|
||||||
|
: 'Discovery',
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
// Reload preference when returning from settings
|
),
|
||||||
_loadRxTxPreference();
|
onTap: () {
|
||||||
});
|
final navigator = Navigator.of(context);
|
||||||
},
|
Future.delayed(Duration.zero, () {
|
||||||
),
|
if (!mounted) return;
|
||||||
],
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.settings),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(AppLocalizations.of(context)!.settings),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => SettingsScreen(
|
||||||
|
onThemeChanged: widget.onThemeChanged,
|
||||||
|
onLocaleChanged: widget.onLocaleChanged,
|
||||||
|
currentTheme: widget.currentTheme,
|
||||||
|
currentLocale: widget.currentLocale,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_loadRxTxPreference();
|
||||||
|
_loadDeveloperModePreference();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -657,6 +787,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
return MessagesTab(
|
return MessagesTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.messages &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
@@ -666,9 +799,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
);
|
);
|
||||||
case _HomeTab.sensors:
|
case _HomeTab.sensors:
|
||||||
return const SensorsTab();
|
return SensorsTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.sensors &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
|
);
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
return MapTab(
|
return MapTab(
|
||||||
onFullscreenChanged: (isFullscreen) {
|
onFullscreenChanged: (isFullscreen) {
|
||||||
@@ -715,7 +853,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
Icons.message,
|
Icons.message,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
),
|
),
|
||||||
text: AppLocalizations.of(context)!.messages,
|
text: 'Chat',
|
||||||
);
|
);
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
return Tab(
|
return Tab(
|
||||||
@@ -999,6 +1137,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
SizedBox(width: isTight ? 8 : 12),
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
if (_showRxTxIndicators)
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
onTap: () => _openLiveTraffic(provider),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -1014,17 +1153,19 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
txActive: provider.txActivity,
|
txActive: provider.txActivity,
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 8,
|
||||||
vertical: 8,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceContainerHigh
|
color: theme.colorScheme.surfaceContainerHigh
|
||||||
.withValues(alpha: 0.85),
|
.withValues(alpha: 0.85),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
_buildActivityBadge(
|
_buildActivityBadge(
|
||||||
@@ -1032,13 +1173,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
count: provider.rxPacketCount,
|
count: provider.rxPacketCount,
|
||||||
isActive: provider.rxActivity,
|
isActive: provider.rxActivity,
|
||||||
activeColor: Colors.green,
|
activeColor: Colors.green,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
_buildActivityBadge(
|
_buildActivityBadge(
|
||||||
label: 'TX',
|
label: 'TX',
|
||||||
count: provider.txPacketCount,
|
count: provider.txPacketCount,
|
||||||
isActive: provider.txActivity,
|
isActive: provider.txActivity,
|
||||||
activeColor: Colors.blue,
|
activeColor: Colors.blue,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
1132
lib/screens/live_traffic_screen.dart
Normal file
1132
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,13 @@ import '../models/message.dart';
|
|||||||
import '../services/background_location_service.dart';
|
import '../services/background_location_service.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/map_marker_service.dart';
|
import '../services/map_marker_service.dart';
|
||||||
|
import '../services/message_destination_preferences.dart';
|
||||||
import '../services/trail_color_service.dart';
|
import '../services/trail_color_service.dart';
|
||||||
import '../widgets/map_debug_info.dart';
|
import '../widgets/map_debug_info.dart';
|
||||||
import '../widgets/map/compass_widget.dart';
|
import '../widgets/map/compass_widget.dart';
|
||||||
import '../widgets/map/detailed_compass_dialog.dart';
|
import '../widgets/map/detailed_compass_dialog.dart';
|
||||||
import '../widgets/map/drawing_layer.dart';
|
import '../widgets/map/drawing_layer.dart';
|
||||||
|
import '../widgets/map/drawing_toolbar.dart';
|
||||||
import '../widgets/map/location_trail_layer.dart';
|
import '../widgets/map/location_trail_layer.dart';
|
||||||
import '../widgets/map/trail_controls.dart';
|
import '../widgets/map/trail_controls.dart';
|
||||||
import '../widgets/map/map_message_overlay.dart';
|
import '../widgets/map/map_message_overlay.dart';
|
||||||
@@ -815,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showSarMarkerActions(
|
||||||
|
SarMarker marker,
|
||||||
|
MessagesProvider messagesProvider,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
) async {
|
||||||
|
final message = messagesProvider.getMessageById(marker.id);
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
final theme = Theme.of(sheetContext);
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
marker.displayName,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
marker.senderName != null
|
||||||
|
? '${marker.timeAgo} • ${marker.senderName}'
|
||||||
|
: marker.timeAgo,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(marker.notes!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (message != null)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
|
title: const Text('Open message'),
|
||||||
|
subtitle: const Text('Jump to the related SAR message'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _openSarMarkerMessage(
|
||||||
|
message,
|
||||||
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
title: Text(
|
||||||
|
'Remove marker',
|
||||||
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
message != null
|
||||||
|
? 'This also removes the linked SAR message.'
|
||||||
|
: 'Hide this marker from the map.',
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final confirmed = await _confirmSarMarkerRemoval(
|
||||||
|
hasMessage: message != null,
|
||||||
|
);
|
||||||
|
if (!mounted ||
|
||||||
|
!sheetContext.mounted ||
|
||||||
|
confirmed != true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await messagesProvider.removeSarMarkerPermanently(
|
||||||
|
marker.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Remove SAR marker'),
|
||||||
|
content: Text(
|
||||||
|
hasMessage
|
||||||
|
? 'This will remove the marker and its linked chat message.'
|
||||||
|
: 'This will hide the marker from the map, even if it is not visible in chat.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: Text(AppLocalizations.of(dialogContext)!.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(AppLocalizations.of(dialogContext)!.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openSarMarkerMessage(
|
||||||
|
Message message,
|
||||||
|
MessagesProvider messagesProvider,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
) async {
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
final channelContact = contactsProvider.channels.where((contact) {
|
||||||
|
return contact.publicKey.length > 1 &&
|
||||||
|
contact.publicKey[1] == (message.channelIdx ?? 0);
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
messagesProvider.navigateToDestination(
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel,
|
||||||
|
recipientPublicKeyHex: channelContact?.publicKeyHex,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Contact? destinationContact;
|
||||||
|
|
||||||
|
if (message.recipientPublicKey != null) {
|
||||||
|
destinationContact = contactsProvider.contacts.where((contact) {
|
||||||
|
return contact.publicKey.length >=
|
||||||
|
message.recipientPublicKey!.length &&
|
||||||
|
contact.publicKey.matches(message.recipientPublicKey!);
|
||||||
|
}).firstOrNull;
|
||||||
|
} else if (message.senderPublicKeyPrefix != null &&
|
||||||
|
message.senderPublicKeyPrefix!.length >= 6) {
|
||||||
|
destinationContact = contactsProvider.findContactByPrefix(
|
||||||
|
message.senderPublicKeyPrefix!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destinationContact != null) {
|
||||||
|
messagesProvider.navigateToDestination(
|
||||||
|
destinationContact.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact,
|
||||||
|
recipientPublicKeyHex: destinationContact.publicKeyHex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProvider.navigateToMessage(message.id);
|
||||||
|
widget.onNavigateToMessages?.call();
|
||||||
|
}
|
||||||
|
|
||||||
/// Show SAR dialog with pre-populated location from map long press
|
/// Show SAR dialog with pre-populated location from map long press
|
||||||
void _showSarDialogWithLocation(LatLng location) {
|
void _showSarDialogWithLocation(LatLng location) {
|
||||||
// Create a Position object from the LatLng coordinates
|
// Create a Position object from the LatLng coordinates
|
||||||
@@ -1118,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>(
|
return Consumer4<
|
||||||
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) {
|
ContactsProvider,
|
||||||
final contactsWithLocation = contactsProvider.contactsWithLocation;
|
MessagesProvider,
|
||||||
|
DrawingProvider,
|
||||||
|
MapProvider
|
||||||
|
>(
|
||||||
|
builder: (
|
||||||
|
context,
|
||||||
|
contactsProvider,
|
||||||
|
messagesProvider,
|
||||||
|
drawingProvider,
|
||||||
|
mapProvider,
|
||||||
|
child,
|
||||||
|
) {
|
||||||
|
final allContactsWithLocation = contactsProvider.contactsWithLocation;
|
||||||
|
final contactsWithLocation = mapProvider.hideRepeatersOnMap
|
||||||
|
? allContactsWithLocation
|
||||||
|
.where((contact) => !contact.isRepeater)
|
||||||
|
.toList()
|
||||||
|
: allContactsWithLocation;
|
||||||
// Filter SAR markers based on visibility toggle
|
// Filter SAR markers based on visibility toggle
|
||||||
final allSarMarkers = messagesProvider.sarMarkers;
|
final allSarMarkers = messagesProvider.sarMarkers;
|
||||||
final sarMarkers = drawingProvider.showSarMarkers
|
final sarMarkers = drawingProvider.showSarMarkers
|
||||||
@@ -1718,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
onTap: (contact) {
|
onTap: (contact) {
|
||||||
_showDetailedCompassWithContact(
|
_showDetailedCompassWithContact(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
contact,
|
contact,
|
||||||
);
|
);
|
||||||
@@ -1730,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
context: context,
|
context: context,
|
||||||
mapRotation: _getMapRotation(),
|
mapRotation: _getMapRotation(),
|
||||||
onTap: (marker) {
|
onTap: (marker) {
|
||||||
// Navigate to the corresponding message in Messages tab
|
_showSarMarkerActions(
|
||||||
messagesProvider.navigateToMessage(marker.id);
|
marker,
|
||||||
widget.onNavigateToMessages?.call();
|
messagesProvider,
|
||||||
|
contactsProvider,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// User location marker with directional pointer
|
// User location marker with directional pointer
|
||||||
@@ -2020,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _showDetailedCompass(
|
onTap: () => _showDetailedCompass(
|
||||||
context,
|
context,
|
||||||
contactsProvider.contactsWithLocation,
|
contactsWithLocation,
|
||||||
messagesProvider.sarMarkers,
|
messagesProvider.sarMarkers,
|
||||||
),
|
),
|
||||||
child: CompassWidget(
|
child: CompassWidget(
|
||||||
@@ -2131,6 +2320,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
|
|||||||
right: 16,
|
right: 16,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
const DrawingToolbar(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
// Hide other buttons when in drawing mode
|
// Hide other buttons when in drawing mode
|
||||||
if (!drawingProvider.isDrawing) ...[
|
if (!drawingProvider.isDrawing) ...[
|
||||||
// Current Location - always center to GPS
|
// Current Location - always center to GPS
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
|
|||||||
|
|
||||||
class MessagesTab extends StatefulWidget {
|
class MessagesTab extends StatefulWidget {
|
||||||
final VoidCallback? onNavigateToMap;
|
final VoidCallback? onNavigateToMap;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
const MessagesTab({super.key, this.onNavigateToMap});
|
const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MessagesTab> createState() => _MessagesTabState();
|
State<MessagesTab> createState() => _MessagesTabState();
|
||||||
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
static const int _maxContactMessageBytes = 156;
|
static const int _maxContactMessageBytes = 156;
|
||||||
static const int _maxChannelMessageBytes = 127;
|
static const int _maxChannelMessageBytes = 127;
|
||||||
static const double _composerOverlayHeight = 148;
|
static const double _composerOverlayHeight = 148;
|
||||||
|
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
int _messageByteCount = 0;
|
int _messageByteCount = 0;
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
Timer? _channelReadTimer;
|
||||||
|
String? _pendingChannelReadKey;
|
||||||
|
bool _suppressMentionTrigger = false;
|
||||||
|
TextRange? _activeMentionRange;
|
||||||
|
String _mentionQuery = '';
|
||||||
|
List<Contact> _mentionSuggestions = const [];
|
||||||
|
|
||||||
// Message destination state
|
// Message destination state
|
||||||
String _destinationType =
|
String _destinationType =
|
||||||
@@ -71,7 +79,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isSendingVoice = false;
|
bool _isSendingVoice = false;
|
||||||
static const int _maxVoicePackets = 10;
|
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
|
||||||
static const double _silenceRmsThreshold = 500.0;
|
static const double _silenceRmsThreshold = 500.0;
|
||||||
static const double _silencePeakThreshold = 1400.0;
|
static const double _silencePeakThreshold = 1400.0;
|
||||||
static const int _maxInteriorSilentChunks = 2;
|
static const int _maxInteriorSilentChunks = 2;
|
||||||
@@ -85,18 +93,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_textController.addListener(_updateCharacterCount);
|
_textController.addListener(_handleComposerChanged);
|
||||||
|
_focusNode.addListener(_handleFocusChanged);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceBitrate();
|
_loadVoiceSettings();
|
||||||
// Mark all messages as read when tab is opened
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitrate() async {
|
Future<void> _loadVoiceSettings() async {
|
||||||
final bitrate = await VoiceBitratePreferences.getBitrate();
|
final bitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -117,29 +124,52 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
_voiceStreamSub?.cancel();
|
_voiceStreamSub?.cancel();
|
||||||
_voiceRecorder.dispose();
|
_voiceRecorder.dispose();
|
||||||
|
_focusNode.removeListener(_handleFocusChanged);
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant MessagesTab oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
|
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _checkForNavigationRequest() {
|
void _checkForNavigationRequest() {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final targetMessageId = messagesProvider.targetMessageId;
|
final targetMessageId = messagesProvider.targetMessageId;
|
||||||
|
final targetDestinationType = messagesProvider.targetDestinationType;
|
||||||
|
|
||||||
if (targetMessageId != null) {
|
if (targetMessageId != null) {
|
||||||
_scrollToMessage(targetMessageId);
|
_scrollToMessage(targetMessageId);
|
||||||
messagesProvider.clearMessageNavigation();
|
messagesProvider.clearMessageNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetDestinationType != null) {
|
||||||
|
_applyPendingDestination(
|
||||||
|
type: targetDestinationType,
|
||||||
|
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
|
||||||
|
);
|
||||||
|
messagesProvider.clearDestinationNavigation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToMessage(String messageId) {
|
void _scrollToMessage(String messageId) {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
|
|
||||||
final messageIndex = messages.indexWhere((m) => m.id == messageId);
|
final messageIndex = messages.indexWhere(
|
||||||
|
(entry) => entry.message.id == messageId,
|
||||||
|
);
|
||||||
|
|
||||||
if (messageIndex != -1 && _scrollController.hasClients) {
|
if (messageIndex != -1 && _scrollController.hasClients) {
|
||||||
// Calculate position - accounting for reverse list
|
// Calculate position - accounting for reverse list
|
||||||
@@ -170,12 +200,130 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleComposerChanged() {
|
||||||
|
_updateCharacterCount();
|
||||||
|
|
||||||
|
if (_suppressMentionTrigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMentionSuggestions(_textController.value);
|
||||||
|
}
|
||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_messageByteCount = utf8.encode(_textController.text).length;
|
_messageByteCount = utf8.encode(_textController.text).length;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleFocusChanged() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
_clearMentionSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_suppressMentionTrigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateMentionSuggestions(_textController.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateMentionSuggestions(TextEditingValue value) {
|
||||||
|
final triggerRange = _getMentionTriggerRange(value);
|
||||||
|
if (triggerRange == null) {
|
||||||
|
_clearMentionSuggestions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = value.text.substring(
|
||||||
|
triggerRange.start + 1,
|
||||||
|
triggerRange.end,
|
||||||
|
);
|
||||||
|
final contacts = _buildMentionSuggestions(query);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = triggerRange;
|
||||||
|
_mentionQuery = query;
|
||||||
|
_mentionSuggestions = contacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearMentionSuggestions() {
|
||||||
|
if (_activeMentionRange == null &&
|
||||||
|
_mentionQuery.isEmpty &&
|
||||||
|
_mentionSuggestions.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_activeMentionRange = null;
|
||||||
|
_mentionQuery = '';
|
||||||
|
_mentionSuggestions = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
TextRange? _getMentionTriggerRange(TextEditingValue value) {
|
||||||
|
if (!value.selection.isValid || !value.selection.isCollapsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cursorOffset = value.selection.baseOffset;
|
||||||
|
if (cursorOffset <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = value.text;
|
||||||
|
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
|
||||||
|
if (triggerStart == -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
|
||||||
|
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = text.substring(triggerStart + 1, cursorOffset);
|
||||||
|
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextRange(start: triggerStart, end: cursorOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _buildMentionSuggestions(String query) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
final contacts =
|
||||||
|
contactsProvider.contacts
|
||||||
|
.where((contact) => contact.type == ContactType.chat)
|
||||||
|
.where((contact) {
|
||||||
|
if (normalizedQuery.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(
|
||||||
|
normalizedQuery,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aName = a.displayName.toLowerCase();
|
||||||
|
final bName = b.displayName.toLowerCase();
|
||||||
|
final aStarts =
|
||||||
|
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
|
||||||
|
final bStarts =
|
||||||
|
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
|
||||||
|
if (aStarts != bStarts) {
|
||||||
|
return aStarts ? -1 : 1;
|
||||||
|
}
|
||||||
|
return aName.compareTo(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return contacts.take(8).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
int get _maxMessageBytes =>
|
int get _maxMessageBytes =>
|
||||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||||
? _maxChannelMessageBytes
|
? _maxChannelMessageBytes
|
||||||
@@ -258,6 +406,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
void _showRecipientSelector() {
|
void _showRecipientSelector() {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
// Filter contacts by type
|
// Filter contacts by type
|
||||||
final contacts = contactsProvider.contacts
|
final contacts = contactsProvider.contacts
|
||||||
@@ -278,6 +427,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
|
unreadCount: messagesProvider.unreadCount,
|
||||||
|
unreadCountsByPublicKey: {
|
||||||
|
for (final contact in [...contacts, ...rooms, ...channels])
|
||||||
|
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
|
||||||
|
contact,
|
||||||
|
),
|
||||||
|
},
|
||||||
currentDestinationType: _destinationType,
|
currentDestinationType: _destinationType,
|
||||||
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
||||||
onSelect: _onRecipientSelected,
|
onSelect: _onRecipientSelected,
|
||||||
@@ -292,6 +448,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
@@ -304,9 +462,139 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyPendingDestination({
|
||||||
|
required String type,
|
||||||
|
String? recipientPublicKeyHex,
|
||||||
|
}) async {
|
||||||
|
Contact? recipient;
|
||||||
|
if (recipientPublicKeyHex != null) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
recipient = contactsProvider.contacts.where((contact) {
|
||||||
|
return contact.publicKeyHex == recipientPublicKeyHex;
|
||||||
|
}).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(type, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
|
||||||
|
final trimmedName = displayName.trim();
|
||||||
|
if (trimmedName.isEmpty) return;
|
||||||
|
|
||||||
|
final mention = '@[$trimmedName] ';
|
||||||
|
final value = _textController.value;
|
||||||
|
final selection = replacementRange ?? value.selection;
|
||||||
|
final hasSelection =
|
||||||
|
selection.isValid &&
|
||||||
|
selection.start >= 0 &&
|
||||||
|
selection.end >= selection.start;
|
||||||
|
|
||||||
|
final start = hasSelection ? selection.start : value.text.length;
|
||||||
|
final end = hasSelection ? selection.end : value.text.length;
|
||||||
|
final nextText = value.text.replaceRange(start, end, mention);
|
||||||
|
final nextOffset = start + mention.length;
|
||||||
|
|
||||||
|
_suppressMentionTrigger = true;
|
||||||
|
_textController.value = value.copyWith(
|
||||||
|
text: nextText,
|
||||||
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_suppressMentionTrigger = false;
|
||||||
|
_clearMentionSuggestions();
|
||||||
|
_enforceMessageByteLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectMention(Contact contact) {
|
||||||
|
_insertReplyMention(
|
||||||
|
contact.displayName,
|
||||||
|
replacementRange: _activeMentionRange,
|
||||||
|
);
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replyToMessage(Message message) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
Contact? senderContact;
|
||||||
|
String? senderDisplayName;
|
||||||
|
|
||||||
|
String destinationType;
|
||||||
|
Contact? recipient;
|
||||||
|
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
final channelIdx = message.channelIdx ?? 0;
|
||||||
|
destinationType = MessageDestinationPreferences.destinationTypeChannel;
|
||||||
|
|
||||||
|
if (channelIdx != 0) {
|
||||||
|
recipient = contactsProvider.channels.where((contact) {
|
||||||
|
return contact.publicKey.length > 1 &&
|
||||||
|
contact.publicKey[1] == channelIdx;
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
}
|
||||||
|
senderDisplayName =
|
||||||
|
senderContact?.displayName ?? message.senderName?.trim();
|
||||||
|
} else {
|
||||||
|
final roomRecipient = message.recipientPublicKey == null
|
||||||
|
? null
|
||||||
|
: contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||||
|
|
||||||
|
if (roomRecipient?.isRoom == true) {
|
||||||
|
destinationType = MessageDestinationPreferences.destinationTypeRoom;
|
||||||
|
recipient = roomRecipient;
|
||||||
|
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
}
|
||||||
|
senderDisplayName =
|
||||||
|
senderContact?.displayName ?? message.senderName?.trim();
|
||||||
|
} else {
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationType = recipient.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(destinationType, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
if ((message.isChannelMessage || recipient?.isRoom == true) &&
|
||||||
|
senderDisplayName != null &&
|
||||||
|
senderDisplayName.isNotEmpty) {
|
||||||
|
_insertReplyMention(senderDisplayName);
|
||||||
|
}
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get icon for current destination type
|
/// Get icon for current destination type
|
||||||
IconData _getDestinationIcon() {
|
IconData _getDestinationIcon() {
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return Icons.all_inbox;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
return Icons.public;
|
return Icons.public;
|
||||||
} else if (_destinationType ==
|
} else if (_destinationType ==
|
||||||
@@ -318,6 +606,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getDestinationLabel() {
|
String _getDestinationLabel() {
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return AppLocalizations.of(context)!.showAll;
|
||||||
|
}
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
@@ -343,6 +634,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, 'Select a channel, contact, or room first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_textController.clear();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check destination type and send accordingly
|
// Check destination type and send accordingly
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
@@ -373,12 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
|
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
_textController.clear();
|
_markCurrentDestinationAsRead();
|
||||||
_focusNode.unfocus();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (_textController.text.isEmpty) {
|
||||||
|
_textController.text = text;
|
||||||
|
_textController.selection = TextSelection.collapsed(
|
||||||
|
offset: _textController.text.length,
|
||||||
|
);
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
ToastLogger.error(context, 'Failed to send: $e');
|
ToastLogger.error(context, 'Failed to send: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +998,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Future<void> _startVoiceRecording() async {
|
Future<void> _startVoiceRecording() async {
|
||||||
if (_isSendingVoice || _isRecording) return;
|
if (_isSendingVoice || _isRecording) return;
|
||||||
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
||||||
// Read fresh bitrate preference so settings changes apply immediately.
|
// Read fresh voice preferences so settings changes apply immediately.
|
||||||
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -731,11 +1037,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedVoiceBitrate,
|
_selectedVoiceBitrate,
|
||||||
);
|
);
|
||||||
final packetDuration = Duration(
|
final packetDuration = Duration(
|
||||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
milliseconds: _activeVoiceMode!.packetDurationMs,
|
||||||
);
|
);
|
||||||
|
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
|
||||||
);
|
);
|
||||||
|
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
@@ -744,9 +1051,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
try {
|
try {
|
||||||
final stream = _voiceRecorder.startCapture(
|
final stream = _voiceRecorder.startCapture(
|
||||||
chunkDuration: packetDuration,
|
chunkDuration: packetDuration,
|
||||||
|
sampleRateHz: _activeVoiceMode!.sampleRateHz,
|
||||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||||
|
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
);
|
);
|
||||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||||
_voiceStreamSub = stream.listen(
|
_voiceStreamSub = stream.listen(
|
||||||
@@ -757,7 +1068,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||||
);
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
if (_recordedChunks.length >= maxVoicePackets) {
|
||||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||||
_stopAndSendVoice();
|
_stopAndSendVoice();
|
||||||
}
|
}
|
||||||
@@ -774,6 +1085,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _maxVoicePacketsForMode(VoicePacketMode mode) {
|
||||||
|
final packets =
|
||||||
|
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
|
||||||
|
return packets < 1 ? 1 : packets;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _stopAndSendVoice() async {
|
Future<void> _stopAndSendVoice() async {
|
||||||
if (!_isRecording) return;
|
if (!_isRecording) return;
|
||||||
final trimSilenceEnabled = context
|
final trimSilenceEnabled = context
|
||||||
@@ -788,9 +1105,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _voiceRecorder.stopCapture();
|
await _voiceRecorder.stopCapture();
|
||||||
|
|
||||||
final rawChunks = List<Int16List>.from(_recordedChunks);
|
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||||
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
final trimmedChunks = trimSilenceEnabled
|
||||||
|
? _trimSilence(rawChunks)
|
||||||
|
: rawChunks;
|
||||||
final sessionId = _currentVoiceSessionId;
|
final sessionId = _currentVoiceSessionId;
|
||||||
final mode = _activeVoiceMode;
|
final mode = _activeVoiceMode;
|
||||||
|
final chunks = mode == null
|
||||||
|
? trimmedChunks
|
||||||
|
: _prepareChunksForSending(trimmedChunks, mode);
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -979,6 +1301,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Int16List> _prepareChunksForSending(
|
||||||
|
List<Int16List> chunks,
|
||||||
|
VoicePacketMode mode,
|
||||||
|
) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||||
if (chunks.isEmpty) return chunks;
|
if (chunks.isEmpty) return chunks;
|
||||||
|
|
||||||
@@ -1008,16 +1337,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bool _isSilentChunk(Int16List chunk) {
|
bool _isSilentChunk(Int16List chunk) {
|
||||||
if (chunk.isEmpty) return true;
|
if (chunk.isEmpty) return true;
|
||||||
|
|
||||||
|
final rms = _chunkRms(chunk);
|
||||||
|
final peak = _chunkPeak(chunk);
|
||||||
|
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _chunkRms(Int16List chunk) {
|
||||||
var sumSquares = 0.0;
|
var sumSquares = 0.0;
|
||||||
|
for (final sample in chunk) {
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
return math.sqrt(sumSquares / chunk.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _chunkPeak(Int16List chunk) {
|
||||||
var peak = 0;
|
var peak = 0;
|
||||||
for (final sample in chunk) {
|
for (final sample in chunk) {
|
||||||
final absSample = sample.abs();
|
final absSample = sample.abs();
|
||||||
if (absSample > peak) peak = absSample;
|
if (absSample > peak) {
|
||||||
sumSquares += sample * sample;
|
peak = absSample;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return peak;
|
||||||
final rms = math.sqrt(sumSquares / chunk.length);
|
|
||||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isPublicChannelSelected() {
|
bool _isPublicChannelSelected() {
|
||||||
@@ -1107,6 +1448,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.search),
|
||||||
|
title: const Text('Search messages'),
|
||||||
|
onTap: () async {
|
||||||
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
|
_showFilteredMessageSearch();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_location_alt),
|
leading: const Icon(Icons.add_location_alt),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||||
@@ -1174,6 +1524,116 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFilteredMessageSearch() {
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final scopedMessages = _getFilteredMessages(messagesProvider);
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
final matches = query.isEmpty
|
||||||
|
? scopedMessages
|
||||||
|
: scopedMessages.where((message) {
|
||||||
|
return message.text.toLowerCase().contains(query) ||
|
||||||
|
(message.senderName?.toLowerCase().contains(query) ??
|
||||||
|
false);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search in current filter',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: searchController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
searchController.clear();
|
||||||
|
setModalState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setModalState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: matches.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
query.isEmpty
|
||||||
|
? 'No messages in this filter'
|
||||||
|
: 'No matches in this filter',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: matches.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = matches[index];
|
||||||
|
final title =
|
||||||
|
message.senderName?.trim().isNotEmpty ==
|
||||||
|
true
|
||||||
|
? message.senderName!
|
||||||
|
: message.isSentMessage
|
||||||
|
? 'You'
|
||||||
|
: _getDestinationLabel();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
message.text,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_scrollToMessage(message.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).whenComplete(searchController.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDestinationAvatar(BuildContext context) {
|
Widget _buildDestinationAvatar(BuildContext context) {
|
||||||
final recipient = _selectedRecipient;
|
final recipient = _selectedRecipient;
|
||||||
if (recipient != null) {
|
if (recipient != null) {
|
||||||
@@ -1432,6 +1892,65 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markCurrentDestinationAsRead() {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
|
context.read<MessagesProvider>().markDestinationAsRead(
|
||||||
|
destinationType: _destinationType,
|
||||||
|
contact: _selectedRecipient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
|
||||||
|
if (!widget.isActive ||
|
||||||
|
_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
|
||||||
|
|
||||||
|
if (unreadCount <= 0) {
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextKey = '$channelIdx:$unreadCount';
|
||||||
|
if (_pendingChannelReadKey == nextKey &&
|
||||||
|
_channelReadTimer?.isActive == true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_channelReadTimer?.cancel();
|
||||||
|
_pendingChannelReadKey = nextKey;
|
||||||
|
_channelReadTimer = Timer(_channelAutoReadDelay, () {
|
||||||
|
if (!mounted || !widget.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_destinationType !=
|
||||||
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
|
if (currentChannelIdx != channelIdx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final latestProvider = context.read<MessagesProvider>();
|
||||||
|
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
@@ -1439,17 +1958,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
List<Message> filteredMessages;
|
List<Message> filteredMessages;
|
||||||
|
|
||||||
// If channel destination is selected, filter by selected channel.
|
// If channel destination is selected, filter by selected channel.
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
filteredMessages = allMessages;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
if (selectedChannelIdx == 0) {
|
filteredMessages = allMessages
|
||||||
// Public channel view keeps showing all messages (current app behavior).
|
.where(
|
||||||
filteredMessages = allMessages;
|
(message) =>
|
||||||
} else {
|
message.isChannelMessage &&
|
||||||
filteredMessages = allMessages
|
(message.channelIdx ?? 0) == selectedChannelIdx,
|
||||||
.where((message) => message.channelIdx == selectedChannelIdx)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If a contact or room is selected, filter by recipient/sender prefixes.
|
// If a contact or room is selected, filter by recipient/sender prefixes.
|
||||||
else if ((_destinationType ==
|
else if ((_destinationType ==
|
||||||
@@ -1458,6 +1978,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
MessageDestinationPreferences.destinationTypeRoom) &&
|
MessageDestinationPreferences.destinationTypeRoom) &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
filteredMessages = allMessages.where((message) {
|
filteredMessages = allMessages.where((message) {
|
||||||
|
if (!message.isContactMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Include messages sent TO this recipient
|
// Include messages sent TO this recipient
|
||||||
if (message.recipientPublicKey != null &&
|
if (message.recipientPublicKey != null &&
|
||||||
message.recipientPublicKey!.length >= 6 &&
|
message.recipientPublicKey!.length >= 6 &&
|
||||||
@@ -1519,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
builder: (context, messagesProvider, child) {
|
builder: (context, messagesProvider, child) {
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
_syncChannelAutoReadTimer(messagesProvider);
|
||||||
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||||
|
|
||||||
@@ -1538,6 +2065,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
onMessageTap: _handleMessageTap,
|
onMessageTap: _handleMessageTap,
|
||||||
|
onReplyToMessage: _replyToMessage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -1556,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bottomPadding: composerBottomPadding,
|
bottomPadding: composerBottomPadding,
|
||||||
destinationLabel: _getDestinationLabel(),
|
destinationLabel: _getDestinationLabel(),
|
||||||
destinationAvatar: _buildDestinationAvatar(context),
|
destinationAvatar: _buildDestinationAvatar(context),
|
||||||
|
mentionSuggestions: _mentionSuggestions,
|
||||||
|
mentionQuery: _mentionQuery,
|
||||||
|
onMentionSelected: _selectMention,
|
||||||
onShowComposerActions: _showComposerActions,
|
onShowComposerActions: _showComposerActions,
|
||||||
onShowRecipientSelector: _showRecipientSelector,
|
onShowRecipientSelector: _showRecipientSelector,
|
||||||
onStartVoiceRecording: _startVoiceRecording,
|
onStartVoiceRecording: _startVoiceRecording,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
class PacketLogScreen extends StatefulWidget {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final contacts = context.watch<ContactsProvider>().contacts;
|
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
|
|
||||||
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
|
||||||
final ownName =
|
|
||||||
connectionProvider.deviceInfo.selfName ??
|
|
||||||
connectionProvider.deviceInfo.displayName;
|
|
||||||
final resolvedPath = decodedRoute?.pathHashes
|
|
||||||
.map(
|
|
||||||
(hash) => LogRxRouteDecoder.resolveHash(
|
|
||||||
hash,
|
|
||||||
contacts: contacts,
|
|
||||||
ownPublicKey: ownPublicKey,
|
|
||||||
ownName: ownName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
|
|
||||||
? resolvedPath.first
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isRx && decodedRoute != null) ...[
|
if (isRx) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_RouteSection(
|
_DecodedRouteSection(log: log),
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath ?? const [],
|
|
||||||
originalSender: originalSender,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
@@ -755,16 +733,96 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DecodedRouteSection extends StatelessWidget {
|
||||||
|
final BlePacketLog log;
|
||||||
|
|
||||||
|
const _DecodedRouteSection({required this.log});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contacts = context.watch<ContactsProvider>().contacts;
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final ownName =
|
||||||
|
connectionProvider.deviceInfo.selfName ??
|
||||||
|
connectionProvider.deviceInfo.displayName;
|
||||||
|
|
||||||
|
return FutureBuilder<List<dynamic>>(
|
||||||
|
future: Future.wait<dynamic>([
|
||||||
|
RouteHashPreferences.getHashSize(),
|
||||||
|
MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final decodedRoute = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: snapshot.data?.first as int?,
|
||||||
|
);
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedNodes = snapshot.hasData
|
||||||
|
? snapshot.data![1] as List<MeshMapNode>
|
||||||
|
: const <MeshMapNode>[];
|
||||||
|
final resolvedPath = decodedRoute.hopHashes
|
||||||
|
.map(
|
||||||
|
(hashHex) => _resolveHashWithFallback(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
cachedNodes: cachedNodes,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedNodeHash _resolveHashWithFallback(
|
||||||
|
String hashHex, {
|
||||||
|
required List<Contact> contacts,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
);
|
||||||
|
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
|
final matches = cachedNodes
|
||||||
|
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return ResolvedNodeHash(
|
||||||
|
hashHex: normalizedHashHex,
|
||||||
|
label: matches.first.name,
|
||||||
|
isOwnNode: false,
|
||||||
|
isUniqueMatch: matches.length == 1,
|
||||||
|
matchCount: matches.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
final ResolvedNodeHash? originalSender;
|
|
||||||
|
|
||||||
const _RouteSection({
|
const _RouteSection({required this.route, required this.path});
|
||||||
required this.route,
|
|
||||||
required this.path,
|
|
||||||
required this.originalSender,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -802,14 +860,14 @@ class _RouteSection extends StatelessWidget {
|
|||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.hub,
|
icon: Icons.hub,
|
||||||
label: 'Hops',
|
label: 'Hops',
|
||||||
value: '${route.pathHashes.length}',
|
value: '${route.hopCount}',
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: 'Hash size',
|
||||||
|
value:
|
||||||
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
|
||||||
_FactCard(
|
|
||||||
icon: Icons.person_pin_circle,
|
|
||||||
label: 'Original sender',
|
|
||||||
value: _nodeLabel(originalSender!),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
775
lib/screens/repeaters_map_screen.dart
Normal file
775
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class RepeatersMapScreen extends StatefulWidget {
|
||||||
|
const RepeatersMapScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
||||||
|
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
||||||
|
static const double _fallbackZoom = 7;
|
||||||
|
static const double _myLocationZoom = 14;
|
||||||
|
|
||||||
|
final flutter_map.MapController _mapController = flutter_map.MapController();
|
||||||
|
|
||||||
|
List<MeshMapNode> _onlineRepeaters = const [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _didFitCamera = false;
|
||||||
|
String? _error;
|
||||||
|
double _currentZoom = _fallbackZoom;
|
||||||
|
LatLng? _myLocation;
|
||||||
|
final Set<String> _addingRepeaters = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadRepeaters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
if (forceRefresh) {
|
||||||
|
_didFitCamera = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nodes = await MeshMapNodesService.fetchNodes(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
} catch (error) {
|
||||||
|
final cached = await MeshMapNodesService.loadCachedNodes();
|
||||||
|
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
_error = repeaters.isEmpty
|
||||||
|
? 'Unable to load repeaters right now.'
|
||||||
|
: 'Showing cached repeaters. Pull to retry.';
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_MapRepeater> _mergeRepeaters({
|
||||||
|
required List<MeshMapNode> onlineNodes,
|
||||||
|
required List<Contact> contactRepeaters,
|
||||||
|
}) {
|
||||||
|
final merged = <String, _MapRepeater>{};
|
||||||
|
|
||||||
|
for (final node in onlineNodes) {
|
||||||
|
merged[node.publicKey] = _MapRepeater(
|
||||||
|
publicKey: node.publicKey,
|
||||||
|
name: node.name,
|
||||||
|
latitude: node.latitude,
|
||||||
|
longitude: node.longitude,
|
||||||
|
isFromContacts: false,
|
||||||
|
isFromOnline: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactRepeaters) {
|
||||||
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = contact.publicKeyHex.toLowerCase();
|
||||||
|
final existing = merged[key];
|
||||||
|
merged[key] = _MapRepeater(
|
||||||
|
publicKey: key,
|
||||||
|
name: contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName
|
||||||
|
: existing?.name ?? 'Unknown repeater',
|
||||||
|
latitude: existing?.latitude ?? location.latitude,
|
||||||
|
longitude: existing?.longitude ?? location.longitude,
|
||||||
|
isFromContacts: true,
|
||||||
|
isFromOnline: existing?.isFromOnline ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaters = merged.values.toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return repeaters;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshMyLocation() async {
|
||||||
|
try {
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_myLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
||||||
|
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repeaters.length == 1) {
|
||||||
|
final repeater = repeaters.first;
|
||||||
|
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
||||||
|
_currentZoom = 13;
|
||||||
|
} else {
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
repeaters
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_didFitCamera = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRepeaterDetails(_MapRepeater repeater) {
|
||||||
|
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
||||||
|
final canAdd = !repeater.isFromContacts;
|
||||||
|
final isConnected = context
|
||||||
|
.read<ConnectionProvider>()
|
||||||
|
.deviceInfo
|
||||||
|
.isConnected;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
repeater.name,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Public key',
|
||||||
|
value: repeater.publicKey,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Coordinates',
|
||||||
|
value:
|
||||||
|
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
_InfoLine(label: 'Source', value: repeater.sourceLabel),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (canAdd)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: !isConnected || isAdding
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _addRepeaterToContacts(repeater);
|
||||||
|
},
|
||||||
|
icon: isAdding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(
|
||||||
|
!isConnected
|
||||||
|
? 'Connect device to add'
|
||||||
|
: 'Add to contacts',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: const Text('Already in contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
||||||
|
if (_addingRepeaters.contains(repeater.publicKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Connect to a device before adding contacts'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.add(repeater.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${repeater.name} added to contacts')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.remove(repeater.publicKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
||||||
|
return Uint8List.fromList(
|
||||||
|
List<int>.generate(
|
||||||
|
normalized.length ~/ 2,
|
||||||
|
(index) => int.parse(
|
||||||
|
normalized.substring(index * 2, index * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactsProvider = context.watch<ContactsProvider>();
|
||||||
|
final repeaters = _mergeRepeaters(
|
||||||
|
onlineNodes: _onlineRepeaters,
|
||||||
|
contactRepeaters: contactsProvider.repeaters,
|
||||||
|
);
|
||||||
|
_fitCameraIfNeeded(repeaters);
|
||||||
|
final clusters = _buildClusters(repeaters);
|
||||||
|
final markers = clusters.map((cluster) {
|
||||||
|
final markerColor = switch (cluster.status) {
|
||||||
|
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
||||||
|
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
||||||
|
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
||||||
|
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cluster.members.length == 1) {
|
||||||
|
final repeater = cluster.members.first;
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 80,
|
||||||
|
height: 100,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _showRepeaterDetails(repeater),
|
||||||
|
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _zoomToCluster(cluster),
|
||||||
|
child: _ClusterMarker(
|
||||||
|
color: markerColor,
|
||||||
|
count: cluster.members.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
final allMarkers = <flutter_map.Marker>[
|
||||||
|
...markers,
|
||||||
|
if (_myLocation != null)
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: _myLocation!,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Repeaters Map'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () => _loadRepeaters(forceRefresh: true),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
flutter_map.FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: _fallbackCenter,
|
||||||
|
initialZoom: _fallbackZoom,
|
||||||
|
onPositionChanged: (camera, hasGesture) {
|
||||||
|
final nextZoom = camera.zoom;
|
||||||
|
if (nextZoom != _currentZoom && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = nextZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'app.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(markers: allMarkers),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(_error!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||||
|
if (!_isLoading && repeaters.isEmpty)
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
_error ?? 'No repeaters available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: _error == null ? 16 : 72,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFF7C3AED),
|
||||||
|
label: 'From contacts',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFFE8681D),
|
||||||
|
label: 'Online only',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${repeaters.length} repeaters',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _jumpToMyLocation,
|
||||||
|
tooltip: 'My location',
|
||||||
|
child: const Icon(Icons.my_location),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _jumpToMyLocation() async {
|
||||||
|
await _refreshMyLocation();
|
||||||
|
final myLocation = _myLocation;
|
||||||
|
if (!mounted || myLocation == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mapController.move(myLocation, _myLocationZoom);
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = _myLocationZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
||||||
|
if (repeaters.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentZoom >= 11.5) {
|
||||||
|
return repeaters
|
||||||
|
.map(
|
||||||
|
(repeater) => _RepeaterCluster(
|
||||||
|
members: [repeater],
|
||||||
|
center: LatLng(repeater.latitude, repeater.longitude),
|
||||||
|
status: repeater.clusterStatus,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cellSize = _gridSizeForZoom(_currentZoom);
|
||||||
|
final buckets = <String, List<_MapRepeater>>{};
|
||||||
|
|
||||||
|
for (final repeater in repeaters) {
|
||||||
|
final latBucket = (repeater.latitude / cellSize).floor();
|
||||||
|
final lonBucket = (repeater.longitude / cellSize).floor();
|
||||||
|
final key = '$latBucket:$lonBucket';
|
||||||
|
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.values.map((members) {
|
||||||
|
final centerLat =
|
||||||
|
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final centerLon =
|
||||||
|
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final statuses = members.map((node) => node.clusterStatus).toSet();
|
||||||
|
return _RepeaterCluster(
|
||||||
|
members: members,
|
||||||
|
center: LatLng(centerLat, centerLon),
|
||||||
|
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _gridSizeForZoom(double zoom) {
|
||||||
|
if (zoom < 5) return 3.0;
|
||||||
|
if (zoom < 7) return 1.5;
|
||||||
|
if (zoom < 9) return 0.7;
|
||||||
|
if (zoom < 10.5) return 0.28;
|
||||||
|
return 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _zoomToCluster(_RepeaterCluster cluster) {
|
||||||
|
if (cluster.members.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
cluster.members
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(64),
|
||||||
|
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoLine extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool monospace;
|
||||||
|
|
||||||
|
const _InfoLine({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.monospace = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 92,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: monospace
|
||||||
|
? const TextStyle(fontFamily: 'monospace')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendRow extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _LegendRow({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeaterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _RepeaterMarker({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClusterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const _ClusterMarker({required this.color, required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
||||||
|
|
||||||
|
class _RepeaterCluster {
|
||||||
|
final List<_MapRepeater> members;
|
||||||
|
final LatLng center;
|
||||||
|
final _ClusterStatus status;
|
||||||
|
|
||||||
|
const _RepeaterCluster({
|
||||||
|
required this.members,
|
||||||
|
required this.center,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapRepeater {
|
||||||
|
final String publicKey;
|
||||||
|
final String name;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final bool isFromContacts;
|
||||||
|
final bool isFromOnline;
|
||||||
|
|
||||||
|
const _MapRepeater({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.name,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.isFromContacts,
|
||||||
|
required this.isFromOnline,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get sourceLabel {
|
||||||
|
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
||||||
|
if (isFromContacts) return 'Contacts';
|
||||||
|
return 'Online';
|
||||||
|
}
|
||||||
|
|
||||||
|
_ClusterStatus get clusterStatus {
|
||||||
|
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
||||||
|
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
||||||
|
return _ClusterStatus.onlineOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io' show Platform;
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -13,20 +14,23 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/drawing_provider.dart';
|
||||||
|
import '../providers/map_provider.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/update_checker_service.dart';
|
import '../services/update_checker_service.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/connection_mode_selector.dart';
|
|
||||||
import '../widgets/update_dialog.dart';
|
import '../widgets/update_dialog.dart';
|
||||||
import 'sar_template_management_screen.dart';
|
import 'sar_template_management_screen.dart';
|
||||||
import 'welcome_wizard_screen.dart';
|
import 'welcome_wizard_screen.dart';
|
||||||
@@ -70,6 +74,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _fastLocationUpdatesEnabled = false;
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
|
bool _rotateMapWithHeading = false;
|
||||||
|
bool _showMapDebugInfo = false;
|
||||||
|
bool _openMapInFullscreen = false;
|
||||||
|
bool _messageNotificationsEnabled = true;
|
||||||
|
bool _sarNotificationsEnabled = true;
|
||||||
|
bool _discoveryNotificationsEnabled = true;
|
||||||
|
bool _updateNotificationsEnabled = true;
|
||||||
|
bool _muteForegroundNotifications = true;
|
||||||
|
bool _isDeveloperModeEnabled = false;
|
||||||
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
|
bool _isClearingOnlineTraceCache = false;
|
||||||
|
int _versionTapCount = 0;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
@@ -81,10 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadPackageInfo();
|
_loadPackageInfo();
|
||||||
_initializeLocationService();
|
_initializeLocationService();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadVoiceBitratePreference();
|
_loadVoicePreferences();
|
||||||
_loadRouteHashSizePreference();
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
_loadFastLocationSettings();
|
_loadFastLocationSettings();
|
||||||
|
_loadDeveloperMode();
|
||||||
|
_loadOnlineTraceCacheStatus();
|
||||||
|
_loadMapPreferences();
|
||||||
|
_loadNotificationPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -114,12 +134,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperMode() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNotificationPreferences() async {
|
||||||
|
final service = NotificationService();
|
||||||
|
await service.initialize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messageNotificationsEnabled = service.messageNotificationsEnabled;
|
||||||
|
_sarNotificationsEnabled = service.sarNotificationsEnabled;
|
||||||
|
_discoveryNotificationsEnabled = service.discoveryNotificationsEnabled;
|
||||||
|
_updateNotificationsEnabled = service.updateNotificationsEnabled;
|
||||||
|
_muteForegroundNotifications = service.muteForegroundNotifications;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOnlineTraceCacheStatus() async {
|
||||||
|
final cachedAt = await MeshMapNodesService.cachedAt();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineTraceCacheUpdatedAt = cachedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleVersionTap() async {
|
||||||
|
if (_isDeveloperModeEnabled) {
|
||||||
|
await DeveloperModeService.setEnabled(false);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = false;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextTapCount = _versionTapCount + 1;
|
||||||
|
if (nextTapCount >= 3) {
|
||||||
|
await DeveloperModeService.setEnabled(true);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = true;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_versionTapCount = nextTapCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveRxTxPreference(bool value) async {
|
Future<void> _saveRxTxPreference(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitratePreference() async {
|
Future<void> _loadMapPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
|
||||||
|
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
|
||||||
|
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveMapPreference(String key, bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoicePreferences() async {
|
||||||
final value = await VoiceBitratePreferences.getBitrate();
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -424,7 +522,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
/// Check for app updates and show notification or dialog
|
/// Check for app updates and show notification or dialog
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only on Android
|
// Only on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -783,6 +881,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearOnlineTraceCache() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear online trace database'),
|
||||||
|
content: const Text(
|
||||||
|
'This removes the cached online node database used as a trace fallback.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(AppLocalizations.of(context)!.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingOnlineTraceCache = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await MeshMapNodesService.clearCache();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineTraceCacheUpdatedAt = null;
|
||||||
|
_isClearingOnlineTraceCache = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Online trace database cleared'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _onlineTraceCacheSubtitle() {
|
||||||
|
final cachedAt = _onlineTraceCacheUpdatedAt;
|
||||||
|
if (cachedAt == null) {
|
||||||
|
return 'No cached online database. Refresh runs in background when internet is available.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
|
||||||
|
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime value) {
|
||||||
|
final local = value.toLocal();
|
||||||
|
String two(int part) => part.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showRouteHashSizeDialog() async {
|
Future<void> _showRouteHashSizeDialog() async {
|
||||||
final selected = await showDialog<int>(
|
final selected = await showDialog<int>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -862,6 +1020,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Notifications'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.chat_bubble_outline),
|
||||||
|
title: const Text('Message notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify for incoming direct and channel messages',
|
||||||
|
),
|
||||||
|
value: _messageNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_messageNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setMessageNotificationsEnabled(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.warning_amber_outlined),
|
||||||
|
title: const Text('SAR alerts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify for incoming SAR markers such as found person or fire',
|
||||||
|
),
|
||||||
|
value: _sarNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_sarNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setSarNotificationsEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.contact_page_outlined),
|
||||||
|
title: const Text('Discovery notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify when new contacts appear in Discovery',
|
||||||
|
),
|
||||||
|
value: _discoveryNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_discoveryNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setDiscoveryNotificationsEnabled(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.system_update),
|
||||||
|
title: const Text('Update notifications'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Notify when a newer app version is available',
|
||||||
|
),
|
||||||
|
value: _updateNotificationsEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_updateNotificationsEnabled = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setUpdateNotificationsEnabled(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.visibility_off_outlined),
|
||||||
|
title: const Text('Mute while app is open'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Do not show local notifications while the app is in the foreground',
|
||||||
|
),
|
||||||
|
value: _muteForegroundNotifications,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_muteForegroundNotifications = value;
|
||||||
|
});
|
||||||
|
await NotificationService().setMuteForegroundNotifications(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
_buildSectionHeader('Navigation'),
|
_buildSectionHeader('Navigation'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
@@ -907,19 +1147,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
_buildSectionHeader('Messaging'),
|
_buildSectionHeader('Messaging'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.person_add_alt_1),
|
|
||||||
title: const Text('Auto-add discovered contacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Automatically fetch and add new contacts when they are discovered',
|
|
||||||
),
|
|
||||||
value: appProvider.autoAddDiscoveredContacts,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.alt_route),
|
leading: const Icon(Icons.alt_route),
|
||||||
title: const Text('Route path byte size'),
|
title: const Text('Route path byte size'),
|
||||||
@@ -929,6 +1156,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _showRouteHashSizeDialog,
|
onTap: _showRouteHashSizeDialog,
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.swap_horiz),
|
||||||
|
title: const Text('Auto route rotation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Rotate between best known direct paths and flood mode for room/contact sends',
|
||||||
|
),
|
||||||
|
value: appProvider.autoRouteRotationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoRouteRotationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: const Text('Nearest repeater fallback'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'After normal retries fail, try one final resend through the nearest repeater',
|
||||||
|
),
|
||||||
|
value: appProvider.nearestRelayFallbackEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleNearestRelayFallbackEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: const Text('Clear path on max retry'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Clear the route only after all retries and final router fallback fail',
|
||||||
|
),
|
||||||
|
value: appProvider.clearPathOnMaxRetry,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleClearPathOnMaxRetry(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
@@ -938,8 +1204,147 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: const Text('Delete all stored message history'),
|
subtitle: const Text('Delete all stored message history'),
|
||||||
onTap: _clearMessages,
|
onTap: _clearMessages,
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => ListTile(
|
||||||
|
leading: const Icon(Icons.format_size),
|
||||||
|
title: const Text('Message font size'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${(appProvider.messageFontScale * 100).round()}% of default',
|
||||||
|
),
|
||||||
|
trailing: SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: Slider(
|
||||||
|
value: appProvider.messageFontScale,
|
||||||
|
min: 0.85,
|
||||||
|
max: 1.4,
|
||||||
|
divisions: 11,
|
||||||
|
label: '${(appProvider.messageFontScale * 100).round()}%',
|
||||||
|
onChanged: (value) {
|
||||||
|
appProvider.setMessageFontScale(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Tracing'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_sync),
|
||||||
|
title: const Text('Online trace database'),
|
||||||
|
subtitle: Text(_onlineTraceCacheSubtitle()),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.delete_sweep,
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Clear online trace database',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Remove the 24-hour cached fallback used when local route matches are incomplete',
|
||||||
|
),
|
||||||
|
enabled: !_isClearingOnlineTraceCache,
|
||||||
|
trailing: _isClearingOnlineTraceCache
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _isClearingOnlineTraceCache
|
||||||
|
? null
|
||||||
|
: _clearOnlineTraceCache,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Map'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.explore),
|
||||||
|
title: const Text('Rotate map with heading'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Rotate the map based on your compass or movement heading',
|
||||||
|
),
|
||||||
|
value: _rotateMapWithHeading,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_rotateMapWithHeading = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_rotate_with_heading', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.bug_report_outlined),
|
||||||
|
title: const Text('Show map debug info'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Display extra map diagnostics and internal state overlays',
|
||||||
|
),
|
||||||
|
value: _showMapDebugInfo,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_showMapDebugInfo = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_show_debug_info', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.fullscreen),
|
||||||
|
title: const Text('Open map in fullscreen'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Start the map tab in fullscreen mode by default',
|
||||||
|
),
|
||||||
|
value: _openMapInFullscreen,
|
||||||
|
onChanged: (value) async {
|
||||||
|
setState(() {
|
||||||
|
_openMapInFullscreen = value;
|
||||||
|
});
|
||||||
|
await _saveMapPreference('map_fullscreen', value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Consumer<DrawingProvider>(
|
||||||
|
builder: (context, drawingProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.fmd_good_outlined),
|
||||||
|
title: const Text('Show SAR markers'),
|
||||||
|
subtitle: const Text('Display SAR markers on the main map'),
|
||||||
|
value: drawingProvider.showSarMarkers,
|
||||||
|
onChanged: (value) {
|
||||||
|
drawingProvider.toggleSarMarkers();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.timeline),
|
||||||
|
title: const Text('Show all contact trails'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Display location trails for all contacts that have history',
|
||||||
|
),
|
||||||
|
value: mapProvider.showAllContactTrails,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await mapProvider.toggleAllContactTrails();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<MapProvider>(
|
||||||
|
builder: (context, mapProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.router_outlined),
|
||||||
|
title: const Text('Hide repeaters on map'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Hide repeater contacts from the main map view',
|
||||||
|
),
|
||||||
|
value: mapProvider.hideRepeatersOnMap,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await mapProvider.setHideRepeatersOnMap(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
_buildSectionHeader('Voice'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
@@ -949,6 +1354,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||||
|
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
echoCancellationEnabled:
|
||||||
|
appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
noiseSuppressionEnabled:
|
||||||
|
appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -995,6 +1405,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.auto_fix_high),
|
||||||
|
title: const Text('Mic auto gain'),
|
||||||
|
subtitle: const Text('Lets the recorder adjust input level'),
|
||||||
|
value: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.hearing_disabled),
|
||||||
|
title: const Text('Echo cancellation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder echo cancellation if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceEchoCancellationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.noise_control_off),
|
||||||
|
title: const Text('Noise suppression'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder noise suppression if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.content_cut),
|
secondary: const Icon(Icons.content_cut),
|
||||||
@@ -1106,9 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
_buildSectionHeader('Network Sharing'),
|
|
||||||
const ConnectionModeSelector(),
|
|
||||||
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
@@ -1197,6 +1641,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
||||||
: 'Loading...',
|
: 'Loading...',
|
||||||
),
|
),
|
||||||
|
onTap: _handleVersionTap,
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.badge),
|
leading: const Icon(Icons.badge),
|
||||||
@@ -1212,7 +1657,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onTap: () => _showAboutDialog(),
|
onTap: () => _showAboutDialog(),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
if (Platform.isAndroid)
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -1430,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch.adaptive(
|
Switch(
|
||||||
value: _showCurrentImagePreview,
|
value: _showCurrentImagePreview,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() => _showCurrentImagePreview = value);
|
setState(() => _showCurrentImagePreview = value);
|
||||||
@@ -1511,6 +1956,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
required bool bandPassEnabled,
|
required bool bandPassEnabled,
|
||||||
required bool compressorEnabled,
|
required bool compressorEnabled,
|
||||||
required bool limiterEnabled,
|
required bool limiterEnabled,
|
||||||
|
required bool autoGainEnabled,
|
||||||
|
required bool echoCancellationEnabled,
|
||||||
|
required bool noiseSuppressionEnabled,
|
||||||
required bool silenceTrimEnabled,
|
required bool silenceTrimEnabled,
|
||||||
}) {
|
}) {
|
||||||
final supported = VoiceBitratePreferences.supportedBitrates;
|
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||||
@@ -1523,6 +1971,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
(bandPassEnabled ? 1 : 0) +
|
(bandPassEnabled ? 1 : 0) +
|
||||||
(compressorEnabled ? 1 : 0) +
|
(compressorEnabled ? 1 : 0) +
|
||||||
(limiterEnabled ? 1 : 0) +
|
(limiterEnabled ? 1 : 0) +
|
||||||
|
(autoGainEnabled ? 1 : 0) +
|
||||||
|
(echoCancellationEnabled ? 1 : 0) +
|
||||||
|
(noiseSuppressionEnabled ? 1 : 0) +
|
||||||
(silenceTrimEnabled ? 1 : 0);
|
(silenceTrimEnabled ? 1 : 0);
|
||||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||||
@@ -1530,7 +1981,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final bwHz = _resolveBandwidthHz(radioBw);
|
final bwHz = _resolveBandwidthHz(radioBw);
|
||||||
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
||||||
const voicePreviewMs = 10000; // 10-second reference clip
|
const voicePreviewMs = 10000; // 10-second reference clip
|
||||||
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
|
final packetDurationMs = voiceMode.packetDurationMs;
|
||||||
final voicePacketCount =
|
final voicePacketCount =
|
||||||
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
||||||
final voiceDirect = estimateVoiceTransmitDuration(
|
final voiceDirect = estimateVoiceTransmitDuration(
|
||||||
@@ -1565,7 +2016,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Bitrate: $bitrate bps',
|
'Codec: ${voiceMode.label} · $bitrate bps',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -1612,6 +2063,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Auto gain',
|
||||||
|
enabled: autoGainEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Echo cancel',
|
||||||
|
enabled: echoCancellationEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Noise suppress',
|
||||||
|
enabled: noiseSuppressionEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1624,7 +2100,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Processing enabled: $enabledCount/4',
|
'Processing enabled: $enabledCount/7',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/wizard_preferences.dart';
|
import '../services/wizard_preferences.dart';
|
||||||
|
import '../widgets/connection_dialog.dart';
|
||||||
|
|
||||||
/// Welcome wizard screen to introduce new users to the app
|
/// Welcome wizard screen to introduce new users to the app
|
||||||
class WelcomeWizardScreen extends StatefulWidget {
|
class WelcomeWizardScreen extends StatefulWidget {
|
||||||
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
|
final TextEditingController _deviceNameController = TextEditingController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
static const int _totalPages = 5;
|
bool _isApplyingDeviceSetup = false;
|
||||||
|
static const int _totalPages = 6;
|
||||||
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
|
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia',
|
||||||
|
label: 'Australia',
|
||||||
|
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
|
||||||
|
frequencyKhz: 915800,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 10,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_narrow',
|
||||||
|
label: 'Australia (Narrow)',
|
||||||
|
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
|
||||||
|
frequencyKhz: 916575,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_sa_wa',
|
||||||
|
label: 'Australia: SA, WA',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_qld',
|
||||||
|
label: 'Australia: QLD',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_narrow',
|
||||||
|
label: 'EU/UK (Narrow)',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_deprecated',
|
||||||
|
label: 'EU/UK (Deprecated)',
|
||||||
|
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 869525,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'czech_republic_narrow',
|
||||||
|
label: 'Czech Republic (Narrow)',
|
||||||
|
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 869432,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_433_long_range',
|
||||||
|
label: 'EU 433MHz (Long Range)',
|
||||||
|
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 433650,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand',
|
||||||
|
label: 'New Zealand',
|
||||||
|
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand_narrow',
|
||||||
|
label: 'New Zealand (Narrow)',
|
||||||
|
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_433',
|
||||||
|
label: 'Portugal 433',
|
||||||
|
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
|
||||||
|
frequencyKhz: 433375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 9,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_868',
|
||||||
|
label: 'Portugal 868',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'switzerland',
|
||||||
|
label: 'Switzerland',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'usa_canada_recommended',
|
||||||
|
label: 'USA/Canada (Recommended)',
|
||||||
|
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 910525,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_narrow',
|
||||||
|
label: 'Vietnam (Narrow)',
|
||||||
|
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_deprecated',
|
||||||
|
label: 'Vietnam (Deprecated)',
|
||||||
|
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
_RadioPreset _selectedPreset = _radioPresets[4];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
||||||
|
_deviceNameController.text =
|
||||||
|
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
|
_deviceNameController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
if (_currentPage < _totalPages - 1) {
|
if (_currentPage == 1) {
|
||||||
|
_handleDeviceSetupAction();
|
||||||
|
} else if (_currentPage < _totalPages - 1) {
|
||||||
_pageController.nextPage(
|
_pageController.nextPage(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openConnectionDialog() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const ConnectionDialog(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||||
|
if (fetchedName.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_deviceNameController.text = fetchedName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDeviceSetupAction() async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
if (!deviceInfo.isConnected) {
|
||||||
|
await _openConnectionDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedName = _deviceNameController.text.trim();
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isApplyingDeviceSetup = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.setAdvertName(trimmedName);
|
||||||
|
await connectionProvider.setRadioParams(
|
||||||
|
frequency: _selectedPreset.frequencyKhz,
|
||||||
|
bandwidth: _selectedPreset.bandwidth,
|
||||||
|
spreadingFactor: _selectedPreset.spreadingFactor,
|
||||||
|
codingRate: _selectedPreset.codingRate,
|
||||||
|
);
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.failedToSave(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isApplyingDeviceSetup = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skipDeviceSetupStep() {
|
||||||
|
if (_isApplyingDeviceSetup) return;
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Top bar with skip button
|
// Top bar with skip button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -96,8 +352,10 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
child: PageView(
|
child: PageView(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
_buildWelcomePage(context, l10n, colorScheme),
|
_buildWelcomePage(context, l10n, colorScheme),
|
||||||
|
_buildConnectDevicePage(context, l10n, colorScheme),
|
||||||
_buildConnectingPage(context, l10n, colorScheme),
|
_buildConnectingPage(context, l10n, colorScheme),
|
||||||
_buildChannelPage(context, l10n, colorScheme),
|
_buildChannelPage(context, l10n, colorScheme),
|
||||||
_buildContactsPage(context, l10n, colorScheme),
|
_buildContactsPage(context, l10n, colorScheme),
|
||||||
@@ -108,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
|
|
||||||
// Page indicators
|
// Page indicators
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
@@ -130,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
|
|
||||||
// Next/Get Started button
|
// Next/Get Started button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _nextPage,
|
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -142,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_currentPage < _totalPages - 1
|
_currentPage == 1
|
||||||
|
? _deviceSetupButtonLabel(context)
|
||||||
|
: _currentPage < _totalPages - 1
|
||||||
? l10n.wizardNext
|
? l10n.wizardNext
|
||||||
: l10n.wizardGetStarted,
|
: l10n.wizardGetStarted,
|
||||||
style: const TextStyle(fontSize: 16),
|
style: const TextStyle(fontSize: 16),
|
||||||
@@ -165,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
icon: Icons.waving_hand,
|
icon: Icons.waving_hand,
|
||||||
iconColor: Colors.orange,
|
iconColor: Colors.orange,
|
||||||
title: l10n.wizardWelcomeTitle,
|
title: l10n.wizardWelcomeTitle,
|
||||||
description: l10n.wizardWelcomeDescription,
|
description: l10n.wizardOverviewDescription,
|
||||||
|
features: [
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.chat_bubble_outline,
|
||||||
|
text: l10n.wizardOverviewFeature1,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.emergency_share,
|
||||||
|
text: l10n.wizardOverviewFeature2,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.settings_input_antenna,
|
||||||
|
text: l10n.wizardOverviewFeature3,
|
||||||
|
),
|
||||||
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,33 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.bluetooth_searching,
|
icon: Icons.forum_rounded,
|
||||||
iconColor: Colors.blue,
|
iconColor: Colors.blue,
|
||||||
title: l10n.wizardConnectingTitle,
|
title: l10n.wizardMessagingTitle,
|
||||||
description: l10n.wizardConnectingDescription,
|
description: l10n.wizardMessagingDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
|
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
|
||||||
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
|
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
|
||||||
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
|
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildConnectDevicePage(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
) {
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final isConnected = deviceInfo.isConnected;
|
||||||
|
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 600,
|
||||||
|
maxHeight: constraints.maxHeight,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildPageHeader(
|
||||||
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
icon: Icons.bluetooth_connected,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
title: l10n.wizardConnectDeviceTitle,
|
||||||
|
description: l10n.wizardConnectDeviceDescription,
|
||||||
|
badge: l10n.wizardSetupBadge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isConnected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.bluetooth_searching,
|
||||||
|
color: isConnected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isConnected
|
||||||
|
? l10n.wizardConnectedToDevice(
|
||||||
|
connectedName ?? l10n.device,
|
||||||
|
)
|
||||||
|
: l10n.wizardNoDeviceConnected,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _openConnectionDialog,
|
||||||
|
child: Text(
|
||||||
|
isConnected ? l10n.change : l10n.connect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _skipDeviceSetupStep,
|
||||||
|
child: Text(l10n.wizardSkipForNow),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _deviceNameController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.wizardDeviceNameLabel,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: l10n.wizardDeviceNameHelp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<_RadioPreset>(
|
||||||
|
initialValue: _selectedPreset,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.wizardConfigRegionLabel,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: l10n.wizardConfigRegionHelp,
|
||||||
|
),
|
||||||
|
items: _radioPresets
|
||||||
|
.map(
|
||||||
|
(preset) => DropdownMenuItem<_RadioPreset>(
|
||||||
|
value: preset,
|
||||||
|
child: Text(
|
||||||
|
preset.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (preset) {
|
||||||
|
if (preset == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedPreset = preset;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_selectedPreset.label,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_selectedPreset.summary,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
...[
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.rule,
|
||||||
|
text: l10n.wizardPresetNote1,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.settings_input_antenna,
|
||||||
|
text: l10n.wizardPresetNote2,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.public,
|
||||||
|
text: l10n.wizardPresetNote3,
|
||||||
|
),
|
||||||
|
].map(
|
||||||
|
(feature) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
child: _buildFeatureRow(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
feature,
|
||||||
|
iconSize: 20,
|
||||||
|
gap: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deviceSetupButtonLabel(BuildContext context) {
|
||||||
|
final isConnected = context
|
||||||
|
.watch<ConnectionProvider>()
|
||||||
|
.deviceInfo
|
||||||
|
.isConnected;
|
||||||
|
if (_isApplyingDeviceSetup) {
|
||||||
|
return AppLocalizations.of(context)!.wizardSaving;
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return isConnected
|
||||||
|
? l10n.wizardSaveAndContinue
|
||||||
|
: l10n.wizardConnectDeviceTitle;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildChannelPage(
|
Widget _buildChannelPage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.campaign,
|
icon: Icons.groups_rounded,
|
||||||
iconColor: Colors.purple,
|
iconColor: Colors.purple,
|
||||||
title: l10n.wizardChannelTitle,
|
title: l10n.wizardNetworkTitle,
|
||||||
description: l10n.wizardChannelDescription,
|
description: l10n.wizardNetworkDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
|
_FeatureItem(
|
||||||
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
|
icon: Icons.person_add_alt_1,
|
||||||
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
|
text: l10n.wizardNetworkFeature1,
|
||||||
|
),
|
||||||
|
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
|
||||||
|
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -214,17 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.people,
|
icon: Icons.map_rounded,
|
||||||
iconColor: Colors.teal,
|
iconColor: Colors.teal,
|
||||||
title: l10n.wizardContactsTitle,
|
title: l10n.wizardMapOpsTitle,
|
||||||
description: l10n.wizardContactsDescription,
|
description: l10n.wizardMapOpsDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
|
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
|
||||||
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
|
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
|
||||||
icon: Icons.battery_std,
|
|
||||||
text: l10n.wizardContactsFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -236,18 +699,17 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.map,
|
icon: Icons.tune_rounded,
|
||||||
iconColor: Colors.red,
|
iconColor: Colors.red,
|
||||||
title: l10n.wizardMapTitle,
|
title: l10n.wizardToolsTitle,
|
||||||
description: l10n.wizardMapDescription,
|
description: l10n.wizardToolsDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
|
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
|
||||||
|
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.person_pin_circle,
|
icon: Icons.analytics_outlined,
|
||||||
text: l10n.wizardMapFeature2,
|
text: l10n.wizardToolsFeature3,
|
||||||
),
|
),
|
||||||
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
|
|
||||||
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -261,69 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
List<_FeatureItem>? features,
|
List<_FeatureItem>? features,
|
||||||
required ColorScheme colorScheme,
|
required ColorScheme colorScheme,
|
||||||
}) {
|
}) {
|
||||||
return SingleChildScrollView(
|
final l10n = AppLocalizations.of(context)!;
|
||||||
padding: const EdgeInsets.all(24.0),
|
return LayoutBuilder(
|
||||||
child: Column(
|
builder: (context, constraints) => Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: ConstrainedBox(
|
||||||
children: [
|
constraints: BoxConstraints(
|
||||||
const SizedBox(height: 20),
|
maxWidth: 560,
|
||||||
// Icon
|
maxHeight: constraints.maxHeight,
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 80, color: iconColor),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
child: Padding(
|
||||||
// Title
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
Text(
|
child: Column(
|
||||||
title,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
_buildPageHeader(
|
||||||
color: colorScheme.onSurface,
|
context: context,
|
||||||
),
|
colorScheme: colorScheme,
|
||||||
textAlign: TextAlign.center,
|
icon: icon,
|
||||||
),
|
iconColor: iconColor,
|
||||||
const SizedBox(height: 16),
|
title: title,
|
||||||
// Description
|
description: description,
|
||||||
Text(
|
badge: l10n.wizardOverviewBadge,
|
||||||
description,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (features != null && features.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Features list
|
|
||||||
...features.map(
|
|
||||||
(feature) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(feature.icon, color: colorScheme.primary, size: 24),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
feature.text,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (features != null && features.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
children: features
|
||||||
|
.map(
|
||||||
|
(feature) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: _buildFeatureRow(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
feature,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader({
|
||||||
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String badge,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 56, color: iconColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContentCard({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFeatureRow(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
_FeatureItem feature, {
|
||||||
|
double iconSize = 22,
|
||||||
|
double gap = 14,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
feature.text,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FeatureItem {
|
class _FeatureItem {
|
||||||
@@ -332,3 +874,23 @@ class _FeatureItem {
|
|||||||
|
|
||||||
_FeatureItem({required this.icon, required this.text});
|
_FeatureItem({required this.icon, required this.text});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RadioPreset {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String summary;
|
||||||
|
final int frequencyKhz;
|
||||||
|
final int bandwidth;
|
||||||
|
final int spreadingFactor;
|
||||||
|
final int codingRate;
|
||||||
|
|
||||||
|
const _RadioPreset({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.summary,
|
||||||
|
required this.frequencyKhz,
|
||||||
|
required this.bandwidth,
|
||||||
|
required this.spreadingFactor,
|
||||||
|
required this.codingRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -58,6 +59,16 @@ class BackgroundLocationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_prefKeyEnabled, true);
|
await prefs.setBool(_prefKeyEnabled, true);
|
||||||
@@ -68,8 +79,7 @@ class BackgroundLocationService {
|
|||||||
try {
|
try {
|
||||||
_positionSubscription =
|
_positionSubscription =
|
||||||
Geolocator.getPositionStream(
|
Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceThreshold.toInt(),
|
distanceFilter: distanceThreshold.toInt(),
|
||||||
),
|
),
|
||||||
).listen((Position position) async {
|
).listen((Position position) async {
|
||||||
@@ -171,4 +181,22 @@ class BackgroundLocationService {
|
|||||||
await startTracking(distanceThreshold: distance);
|
await startTracking(distanceThreshold: distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
/// Cayenne LPP (Low Power Payload) data parser
|
/// Cayenne LPP (Low Power Payload) data parser
|
||||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||||
class CayenneLppParser {
|
class CayenneLppParser {
|
||||||
|
static const int _selfTelemetryChannel = 1;
|
||||||
|
static const int _lppGenericSensor = 100;
|
||||||
|
static const int _lppCurrent = 117;
|
||||||
|
static const int _lppFrequency = 118;
|
||||||
|
static const int _lppPercentage = 120;
|
||||||
|
static const int _lppAltitude = 121;
|
||||||
|
static const int _lppConcentration = 125;
|
||||||
|
static const int _lppPower = 128;
|
||||||
|
static const int _lppSpeed = 129;
|
||||||
|
static const int _lppDistance = 130;
|
||||||
|
static const int _lppEnergy = 131;
|
||||||
|
static const int _lppDirection = 132;
|
||||||
|
static const int _lppUnixTime = 133;
|
||||||
|
static const int _lppColour = 135;
|
||||||
|
static const int _lppGust = 137;
|
||||||
|
static const int _lppDewPoint = 138;
|
||||||
|
static const int _lppRain = 139;
|
||||||
|
static const int _lppSwitch = 142;
|
||||||
|
static const int _lppBinaryBool = 143;
|
||||||
|
static const int _lppBinaryPowerSwitch = 144;
|
||||||
|
static const int _lppBinaryOpen = 145;
|
||||||
|
static const int _lppBinaryBatteryLow = 146;
|
||||||
|
static const int _lppBinaryCharging = 147;
|
||||||
|
static const int _lppBinaryCarbonMonoxide = 148;
|
||||||
|
static const int _lppBinaryCold = 149;
|
||||||
|
static const int _lppBinaryConnectivity = 150;
|
||||||
|
static const int _lppBinaryDoor = 151;
|
||||||
|
static const int _lppBinaryGarageDoor = 152;
|
||||||
|
static const int _lppBinaryGas = 153;
|
||||||
|
static const int _lppBinaryHeat = 154;
|
||||||
|
static const int _lppBinaryLight = 155;
|
||||||
|
static const int _lppBinaryLock = 156;
|
||||||
|
static const int _lppBinaryMoisture = 157;
|
||||||
|
static const int _lppBinaryMotion = 158;
|
||||||
|
static const int _lppBinaryMoving = 159;
|
||||||
|
static const int _lppBinaryOccupancy = 160;
|
||||||
|
static const int _lppBinaryPlug = 161;
|
||||||
|
static const int _lppBinaryPresence = 162;
|
||||||
|
static const int _lppBinaryProblem = 163;
|
||||||
|
static const int _lppBinaryRunning = 164;
|
||||||
|
static const int _lppBinarySafety = 165;
|
||||||
|
static const int _lppBinarySmoke = 166;
|
||||||
|
static const int _lppBinarySound = 167;
|
||||||
|
static const int _lppBinaryTamper = 168;
|
||||||
|
static const int _lppBinaryVibration = 169;
|
||||||
|
static const int _lppBinaryWindow = 170;
|
||||||
|
static const int _lppButtonEvent = 171;
|
||||||
|
static const int _lppDimmer = 172;
|
||||||
|
static const int _lppUv = 173;
|
||||||
|
static const int _lppLightLevel = 174;
|
||||||
|
static const int _lppPm25 = 175;
|
||||||
|
static const int _lppPm10 = 176;
|
||||||
|
static const int _lppCo2 = 177;
|
||||||
|
static const int _lppTvoc = 178;
|
||||||
|
static const int _lppRpm = 179;
|
||||||
|
static const int _lppConductivity = 180;
|
||||||
|
static const int _lppRotation = 181;
|
||||||
|
static const int _lppDuration = 182;
|
||||||
|
static const int _lppAcceleration = 183;
|
||||||
|
static const int _lppGyroRate = 184;
|
||||||
|
static const int _lppVolume = 185;
|
||||||
|
static const int _lppFlowRate = 186;
|
||||||
|
static const int _lppVolumeStorage = 187;
|
||||||
|
static const int _lppWater = 188;
|
||||||
|
static const int _lppGasVolume = 189;
|
||||||
|
static const int _lppMass = 190;
|
||||||
|
static const int _lppSignedSpeed = 191;
|
||||||
|
static const int _lppSignedPower = 192;
|
||||||
|
static const int _lppSignedCurrent = 193;
|
||||||
|
|
||||||
/// Parse Cayenne LPP data into ContactTelemetry
|
/// Parse Cayenne LPP data into ContactTelemetry
|
||||||
static ContactTelemetry parse(Uint8List data) {
|
static ContactTelemetry parse(Uint8List data) {
|
||||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||||
@@ -25,7 +95,8 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
if (fieldCount > 0 &&
|
||||||
|
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||||
'${data.length - reader.remainingBytesCount}',
|
'${data.length - reader.remainingBytesCount}',
|
||||||
@@ -65,14 +136,16 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Analog Input (raw): $rawValue');
|
debugPrint(' Analog Input (raw): $rawValue');
|
||||||
debugPrint(' Analog Input (volts): ${value}V');
|
debugPrint(' Analog Input (volts): ${value}V');
|
||||||
extraSensorData['analog_input_$channel'] = value;
|
if (_isBatteryChannel(channel)) {
|
||||||
// If this is a battery reading
|
|
||||||
if (channel == 0 || channel == 1) {
|
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -85,7 +158,7 @@ class CayenneLppParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppIlluminanceSensor:
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
final value = reader.readUInt16BE();
|
final value = reader.readUInt16BE().toDouble();
|
||||||
debugPrint(' Illuminance: $value lux');
|
debugPrint(' Illuminance: $value lux');
|
||||||
extraSensorData['illuminance_$channel'] = value;
|
extraSensorData['illuminance_$channel'] = value;
|
||||||
break;
|
break;
|
||||||
@@ -98,18 +171,36 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppTemperatureSensor:
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
final rawValue = reader.readInt16BE();
|
final rawValue = reader.readInt16BE();
|
||||||
temperature = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Temperature (raw): $rawValue');
|
debugPrint(' Temperature (raw): $rawValue');
|
||||||
debugPrint(
|
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
|
||||||
' Temperature: ${temperature.toStringAsFixed(1)}°C',
|
if (channel == _selfTelemetryChannel) {
|
||||||
);
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['temperature_$channel'] = value;
|
||||||
|
if (temperature == null) {
|
||||||
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppHumiditySensor:
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
final rawValue = reader.readByte();
|
final rawValue = reader.readByte();
|
||||||
humidity = rawValue / 2.0;
|
final value = rawValue / 2.0;
|
||||||
debugPrint(' Humidity (raw): $rawValue');
|
debugPrint(' Humidity (raw): $rawValue');
|
||||||
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
|
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['humidity_$channel'] = value;
|
||||||
|
if (humidity == null) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppAccelerometer:
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
@@ -126,9 +217,19 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppBarometer:
|
case MeshCoreConstants.lppBarometer:
|
||||||
final rawValue = reader.readUInt16BE();
|
final rawValue = reader.readUInt16BE();
|
||||||
pressure = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Barometer (raw): $rawValue');
|
debugPrint(' Barometer (raw): $rawValue');
|
||||||
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
|
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['pressure_$channel'] = value;
|
||||||
|
if (pressure == null) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppVoltageSensor:
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
@@ -136,12 +237,17 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Voltage (raw): $rawValue');
|
debugPrint(' Voltage (raw): $rawValue');
|
||||||
debugPrint(' Voltage: ${value}V');
|
debugPrint(' Voltage: ${value}V');
|
||||||
// Treat voltage sensor as battery reading
|
if (_isBatteryChannel(channel)) {
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
debugPrint(
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
);
|
debugPrint(
|
||||||
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extraSensorData['voltage_$channel'] = value;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppGyrometer:
|
case MeshCoreConstants.lppGyrometer:
|
||||||
@@ -194,15 +300,328 @@ class CayenneLppParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gpsLocation = LatLng(lat, lon);
|
gpsLocation = LatLng(lat, lon);
|
||||||
|
extraSensorData[_sourceChannelKey('gps')] = channel;
|
||||||
extraSensorData['altitude_$channel'] = alt;
|
extraSensorData['altitude_$channel'] = alt;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case _lppGenericSensor:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Generic Sensor: $value');
|
||||||
|
extraSensorData['generic_sensor_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCurrent:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Current (raw): $rawValue');
|
||||||
|
debugPrint(' Current: ${value}A');
|
||||||
|
extraSensorData['current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFrequency:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Frequency: ${value}Hz');
|
||||||
|
extraSensorData['frequency_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPercentage:
|
||||||
|
final value = reader.readByte().toDouble();
|
||||||
|
debugPrint(' Percentage: $value%');
|
||||||
|
if (_isBatteryChannel(channel)) {
|
||||||
|
batteryPercentage = value;
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['percentage_$channel'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAltitude:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue.toDouble();
|
||||||
|
debugPrint(' Altitude: ${value}m');
|
||||||
|
extraSensorData['altitude_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConcentration:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Concentration: ${value}ppm');
|
||||||
|
extraSensorData['concentration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPower:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Power: ${value}W');
|
||||||
|
extraSensorData['power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSpeed:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Speed: ${value}m/s');
|
||||||
|
extraSensorData['speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDistance:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Distance: ${value}m');
|
||||||
|
extraSensorData['distance_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppEnergy:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Energy: ${value}kWh');
|
||||||
|
extraSensorData['energy_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDirection:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Direction: $value°');
|
||||||
|
extraSensorData['direction_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUnixTime:
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
debugPrint(' Unix time: $value');
|
||||||
|
extraSensorData['unixtime_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppColour:
|
||||||
|
final red = reader.readByte();
|
||||||
|
final green = reader.readByte();
|
||||||
|
final blue = reader.readByte();
|
||||||
|
debugPrint(' Colour: r=$red, g=$green, b=$blue');
|
||||||
|
extraSensorData['colour_$channel'] = {
|
||||||
|
'r': red,
|
||||||
|
'g': green,
|
||||||
|
'b': blue,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGust:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Gust: ${value}m/s');
|
||||||
|
extraSensorData['gust_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDewPoint:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
|
||||||
|
extraSensorData['dew_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRain:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rain: ${value}mm');
|
||||||
|
extraSensorData['rain_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppBinaryBool:
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
case _lppBinaryCold:
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
case _lppBinaryGas:
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
case _lppBinaryLight:
|
||||||
|
case _lppBinaryLock:
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
case _lppBinarySafety:
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
case _lppBinarySound:
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Binary state: $value');
|
||||||
|
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
|
||||||
|
value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppButtonEvent:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Button event: $value');
|
||||||
|
extraSensorData['button_event_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDimmer:
|
||||||
|
final value = _readInt8(reader);
|
||||||
|
debugPrint(' Dimmer: $value');
|
||||||
|
extraSensorData['dimmer_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUv:
|
||||||
|
final value = reader.readByte() / 10.0;
|
||||||
|
debugPrint(' UV index: $value');
|
||||||
|
extraSensorData['uv_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppLightLevel:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Light level: $value');
|
||||||
|
extraSensorData['light_level_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm25:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM2.5: $value');
|
||||||
|
extraSensorData['pm25_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm10:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM10: $value');
|
||||||
|
extraSensorData['pm10_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCo2:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' CO2: $value');
|
||||||
|
extraSensorData['co2_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppTvoc:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' TVOC: $value');
|
||||||
|
extraSensorData['tvoc_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRpm:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' RPM: $value');
|
||||||
|
extraSensorData['rpm_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConductivity:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Conductivity: $value');
|
||||||
|
extraSensorData['conductivity_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRotation:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rotation: $value');
|
||||||
|
extraSensorData['rotation_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDuration:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Duration: $value s');
|
||||||
|
extraSensorData['duration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAcceleration:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Acceleration: $value');
|
||||||
|
extraSensorData['acceleration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGyroRate:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gyro rate: $value');
|
||||||
|
extraSensorData['gyro_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Volume: $value');
|
||||||
|
extraSensorData['volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFlowRate:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Flow rate: $value');
|
||||||
|
extraSensorData['flow_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Storage volume: $value');
|
||||||
|
extraSensorData['volume_storage_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppWater:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Water: $value');
|
||||||
|
extraSensorData['water_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGasVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gas volume: $value');
|
||||||
|
extraSensorData['gas_volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppMass:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Mass: $value');
|
||||||
|
extraSensorData['mass_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Signed speed: $value');
|
||||||
|
extraSensorData['signed_speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedPower:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Signed power: $value');
|
||||||
|
extraSensorData['signed_power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Signed current: $value');
|
||||||
|
extraSensorData['signed_current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSwitch:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Switch: $value');
|
||||||
|
extraSensorData['switch_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
debugPrint(
|
final size = _payloadSizeForType(type);
|
||||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
if (size == null || reader.remainingBytesCount < size) {
|
||||||
);
|
debugPrint(
|
||||||
// Unknown type, skip remaining to avoid parsing errors
|
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||||
reader.skip(reader.remainingBytesCount);
|
);
|
||||||
|
reader.skip(reader.remainingBytesCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||||
|
reader.skip(size);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -258,6 +677,163 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isBatteryChannel(int channel) =>
|
||||||
|
channel == 0 || channel == _selfTelemetryChannel;
|
||||||
|
|
||||||
|
static int _readUInt32BE(BufferReader reader) {
|
||||||
|
final bytes = reader.readBytes(4);
|
||||||
|
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _readInt32BE(BufferReader reader) {
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
if ((value & 0x80000000) != 0) {
|
||||||
|
return value - 0x100000000;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _readInt8(BufferReader reader) {
|
||||||
|
final value = reader.readByte();
|
||||||
|
if ((value & 0x80) != 0) {
|
||||||
|
return value - 0x100;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sourceChannelKey(String fieldKey) =>
|
||||||
|
'__source_channel:$fieldKey';
|
||||||
|
|
||||||
|
static String _binaryMetricKeyForType(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case _lppBinaryBool:
|
||||||
|
return 'binary_bool';
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
return 'binary_power_switch';
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
return 'binary_open';
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
return 'binary_battery_low';
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
return 'binary_charging';
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
return 'binary_carbon_monoxide';
|
||||||
|
case _lppBinaryCold:
|
||||||
|
return 'binary_cold';
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
return 'binary_connectivity';
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
return 'binary_door';
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
return 'binary_garage_door';
|
||||||
|
case _lppBinaryGas:
|
||||||
|
return 'binary_gas';
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
return 'binary_heat';
|
||||||
|
case _lppBinaryLight:
|
||||||
|
return 'binary_light';
|
||||||
|
case _lppBinaryLock:
|
||||||
|
return 'binary_lock';
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
return 'binary_moisture';
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
return 'binary_motion';
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
return 'binary_moving';
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
return 'binary_occupancy';
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
return 'binary_plug';
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
return 'binary_presence';
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
return 'binary_problem';
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
return 'binary_running';
|
||||||
|
case _lppBinarySafety:
|
||||||
|
return 'binary_safety';
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
return 'binary_smoke';
|
||||||
|
case _lppBinarySound:
|
||||||
|
return 'binary_sound';
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
return 'binary_tamper';
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
return 'binary_vibration';
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
return 'binary_window';
|
||||||
|
}
|
||||||
|
return 'binary_state';
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _payloadSizeForType(int type) {
|
||||||
|
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case MeshCoreConstants.lppDigitalInput:
|
||||||
|
case MeshCoreConstants.lppDigitalOutput:
|
||||||
|
case MeshCoreConstants.lppPresenceSensor:
|
||||||
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
|
case _lppPercentage:
|
||||||
|
case _lppSwitch:
|
||||||
|
case _lppButtonEvent:
|
||||||
|
case _lppDimmer:
|
||||||
|
case _lppUv:
|
||||||
|
case _lppLightLevel:
|
||||||
|
return 1;
|
||||||
|
case MeshCoreConstants.lppAnalogInput:
|
||||||
|
case MeshCoreConstants.lppAnalogOutput:
|
||||||
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
|
case MeshCoreConstants.lppBarometer:
|
||||||
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
|
case _lppCurrent:
|
||||||
|
case _lppAltitude:
|
||||||
|
case _lppConcentration:
|
||||||
|
case _lppPower:
|
||||||
|
case _lppSpeed:
|
||||||
|
case _lppDirection:
|
||||||
|
case _lppGust:
|
||||||
|
case _lppDewPoint:
|
||||||
|
case _lppRain:
|
||||||
|
case _lppPm25:
|
||||||
|
case _lppPm10:
|
||||||
|
case _lppCo2:
|
||||||
|
case _lppTvoc:
|
||||||
|
case _lppRpm:
|
||||||
|
case _lppConductivity:
|
||||||
|
case _lppRotation:
|
||||||
|
return 2;
|
||||||
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
|
case MeshCoreConstants.lppGyrometer:
|
||||||
|
return 6;
|
||||||
|
case MeshCoreConstants.lppGps:
|
||||||
|
return 9;
|
||||||
|
case _lppGenericSensor:
|
||||||
|
case _lppFrequency:
|
||||||
|
case _lppDistance:
|
||||||
|
case _lppEnergy:
|
||||||
|
case _lppUnixTime:
|
||||||
|
case _lppDuration:
|
||||||
|
case _lppAcceleration:
|
||||||
|
case _lppGyroRate:
|
||||||
|
case _lppVolume:
|
||||||
|
case _lppFlowRate:
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
case _lppWater:
|
||||||
|
case _lppGasVolume:
|
||||||
|
case _lppMass:
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
case _lppSignedPower:
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
return 4;
|
||||||
|
case _lppColour:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
final start = data.length - remainingBytes;
|
final start = data.length - remainingBytes;
|
||||||
for (int i = start; i < data.length; i++) {
|
for (int i = start; i < data.length; i++) {
|
||||||
|
|||||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class ResolvedContactRoutePlan {
|
||||||
|
final List<String> tokens;
|
||||||
|
final List<Contact> selectedContacts;
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
const ResolvedContactRoutePlan({
|
||||||
|
required this.tokens,
|
||||||
|
required this.selectedContacts,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get canonicalText => tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const ContactRouteResolver._();
|
||||||
|
|
||||||
|
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required Contact recipient,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final recipientLocation = recipient.displayLocation;
|
||||||
|
if (recipientLocation == null) return null;
|
||||||
|
|
||||||
|
final repeaters = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.isRepeater &&
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex != recipient.publicKeyHex,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (repeaters.isEmpty) return null;
|
||||||
|
|
||||||
|
final recipientLatLng = LatLng(
|
||||||
|
recipientLocation.latitude,
|
||||||
|
recipientLocation.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routedCandidates =
|
||||||
|
repeaters
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
).compareTo(
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routedCandidates.isNotEmpty) {
|
||||||
|
final anchor = routedCandidates.first;
|
||||||
|
final tokens = <String>[
|
||||||
|
...anchor.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toUpperCase()),
|
||||||
|
_tokenFor(anchor, hashSize),
|
||||||
|
];
|
||||||
|
final selectedContacts = _matchContactsForTokens(
|
||||||
|
tokens,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
);
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: _dedupeTokens(tokens),
|
||||||
|
selectedContacts: selectedContacts,
|
||||||
|
summary: 'Resolved via known repeater route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||||
|
..sort((a, b) {
|
||||||
|
final progressA = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
a.displayLocation!.latitude,
|
||||||
|
a.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressB = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
b.displayLocation!.latitude,
|
||||||
|
b.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressCompare = progressA.compareTo(progressB);
|
||||||
|
if (progressCompare != 0) return progressCompare;
|
||||||
|
return _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
).compareTo(
|
||||||
|
_scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final selected = <Contact>[];
|
||||||
|
var currentPoint = senderLocation;
|
||||||
|
var currentDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
final maxCorridorDistance = math.max(
|
||||||
|
1500.0,
|
||||||
|
currentDistanceToRecipient * 0.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final repeater in corridorRepeaters) {
|
||||||
|
if (selected.length >= 4) break;
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final distanceToSegment = _distanceToSegmentMeters(
|
||||||
|
repeaterPoint,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (distanceToSegment > maxCorridorDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
repeaterPoint,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distanceFromCurrent = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
currentPoint,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
if (distanceFromCurrent < 100) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.add(repeater);
|
||||||
|
currentPoint = repeaterPoint;
|
||||||
|
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||||
|
|
||||||
|
if (currentDistanceToRecipient < 2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||||
|
selectedContacts: selected,
|
||||||
|
summary: 'Resolved from repeater locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> _matchContactsForTokens(
|
||||||
|
List<String> tokens, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
final matches = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
matches.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _dedupeTokens(List<String> tokens) {
|
||||||
|
final result = <String>[];
|
||||||
|
for (final token in tokens) {
|
||||||
|
if (result.isEmpty || result.last != token) {
|
||||||
|
result.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
return hex.length < length ? hex : hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final toRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
point,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
final corridor = _distanceToSegmentMeters(
|
||||||
|
point,
|
||||||
|
senderLocation,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreKnownRouteRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final baseScore = _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLocation,
|
||||||
|
repeater: repeater,
|
||||||
|
);
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chainContacts = repeater.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(token) => availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex.toUpperCase().startsWith(
|
||||||
|
token.toUpperCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.firstOrNull,
|
||||||
|
)
|
||||||
|
.whereType<Contact>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chainEnd = chainContacts.isNotEmpty
|
||||||
|
? LatLng(
|
||||||
|
chainContacts.last.displayLocation!.latitude,
|
||||||
|
chainContacts.last.displayLocation!.longitude,
|
||||||
|
)
|
||||||
|
: senderLocation;
|
||||||
|
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||||
|
final senderGap = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _progressAlongSegment({
|
||||||
|
required LatLng point,
|
||||||
|
required LatLng start,
|
||||||
|
required LatLng end,
|
||||||
|
}) {
|
||||||
|
final dx = end.longitude - start.longitude;
|
||||||
|
final dy = end.latitude - start.latitude;
|
||||||
|
final lengthSquared = (dx * dx) + (dy * dy);
|
||||||
|
if (lengthSquared == 0) return 0;
|
||||||
|
return (((point.longitude - start.longitude) * dx) +
|
||||||
|
((point.latitude - start.latitude) * dy)) /
|
||||||
|
lengthSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,17 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
|
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
static const int _maxStoredPendingAdverts = 500;
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
@@ -90,6 +94,81 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = jsonEncode(
|
||||||
|
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||||
|
);
|
||||||
|
await prefs.setString(_contactGroupsKey, jsonString);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedContactGroup>> loadContactGroups() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_contactGroupsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||||
|
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||||
|
: adverts;
|
||||||
|
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_pendingAdvertsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_pendingAdvertsKey);
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +202,7 @@ class ContactStorageService {
|
|||||||
'outPathLen': contact.outPathLen,
|
'outPathLen': contact.outPathLen,
|
||||||
'outPath': base64Encode(contact.outPath),
|
'outPath': base64Encode(contact.outPath),
|
||||||
'advName': contact.advName,
|
'advName': contact.advName,
|
||||||
|
'nameOverride': contact.nameOverride,
|
||||||
'lastAdvert': contact.lastAdvert,
|
'lastAdvert': contact.lastAdvert,
|
||||||
'advLat': contact.advLat,
|
'advLat': contact.advLat,
|
||||||
'advLon': contact.advLon,
|
'advLon': contact.advLon,
|
||||||
@@ -130,6 +210,15 @@ class ContactStorageService {
|
|||||||
'telemetry': contact.telemetry != null
|
'telemetry': contact.telemetry != null
|
||||||
? _telemetryToJson(contact.telemetry!)
|
? _telemetryToJson(contact.telemetry!)
|
||||||
: null,
|
: null,
|
||||||
|
'advertHistory': contact.advertHistory
|
||||||
|
.map(
|
||||||
|
(point) => {
|
||||||
|
'lat': point.location.latitude,
|
||||||
|
'lon': point.location.longitude,
|
||||||
|
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +234,7 @@ class ContactStorageService {
|
|||||||
outPathLen: json['outPathLen'] as int,
|
outPathLen: json['outPathLen'] as int,
|
||||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||||
advName: json['advName'] as String,
|
advName: json['advName'] as String,
|
||||||
|
nameOverride: json['nameOverride'] as String?,
|
||||||
lastAdvert: json['lastAdvert'] as int,
|
lastAdvert: json['lastAdvert'] as int,
|
||||||
advLat: json['advLat'] as int,
|
advLat: json['advLat'] as int,
|
||||||
advLon: json['advLon'] as int,
|
advLon: json['advLon'] as int,
|
||||||
@@ -152,6 +242,7 @@ class ContactStorageService {
|
|||||||
telemetry: json['telemetry'] != null
|
telemetry: json['telemetry'] != null
|
||||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||||
@@ -203,4 +294,56 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||||
|
if (json is! List) return [];
|
||||||
|
final result = <AdvertLocation>[];
|
||||||
|
for (final item in json) {
|
||||||
|
if (item is! Map<String, dynamic>) continue;
|
||||||
|
final lat = item['lat'];
|
||||||
|
final lon = item['lon'];
|
||||||
|
final tsMillis = item['tsMillis'];
|
||||||
|
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||||
|
result.add(
|
||||||
|
AdvertLocation(
|
||||||
|
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
'matchPrefixes': group.matchPrefixes,
|
||||||
|
'isAutoGroup': group.isAutoGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: json['id'] as String,
|
||||||
|
sectionKey: json['sectionKey'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
query: json['query'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['createdAtMillis'] as int,
|
||||||
|
),
|
||||||
|
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||||
|
?.map((value) => value as String)
|
||||||
|
.toList(),
|
||||||
|
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
lib/services/developer_mode_service.dart
Normal file
15
lib/services/developer_mode_service.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class DeveloperModeService {
|
||||||
|
static const String _developerModeKey = 'developer_mode_enabled';
|
||||||
|
|
||||||
|
static Future<bool> isEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_developerModeKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_developerModeKey, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
247
lib/services/live_traffic_summary.dart
Normal file
247
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
enum LiveTrafficBusyness { quiet, active, busy }
|
||||||
|
|
||||||
|
class LiveTrafficEntry {
|
||||||
|
final BlePacketLog log;
|
||||||
|
final DecodedLogRxRoute? route;
|
||||||
|
|
||||||
|
const LiveTrafficEntry({required this.log, required this.route});
|
||||||
|
|
||||||
|
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||||
|
|
||||||
|
int? get hopCount => route?.hopCount;
|
||||||
|
|
||||||
|
String get payloadLabel {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||||
|
}
|
||||||
|
return payloadTypeLabel(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get payloadMeaning {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) return null;
|
||||||
|
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get routePreview {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||||
|
return 'Direct packet';
|
||||||
|
}
|
||||||
|
return decodedRoute.hopHashes
|
||||||
|
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||||
|
.join(' -> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response';
|
||||||
|
case 0x02:
|
||||||
|
return 'Text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Ack';
|
||||||
|
case 0x04:
|
||||||
|
return 'Advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Group text';
|
||||||
|
case 0x06:
|
||||||
|
return 'Group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path';
|
||||||
|
case 0x0A:
|
||||||
|
return 'Multipart packet';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control packet';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeMeaning(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request (destination/source hashes + MAC)';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response to Request or Anonymous request';
|
||||||
|
case 0x02:
|
||||||
|
return 'Plain text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Simple acknowledgement';
|
||||||
|
case 0x04:
|
||||||
|
return 'Node advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Unverified group text message';
|
||||||
|
case 0x06:
|
||||||
|
return 'Unverified group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Generic anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path payload';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path collecting hop SNR';
|
||||||
|
case 0x0A:
|
||||||
|
return 'One packet from a multipart set';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control or discovery packet';
|
||||||
|
default:
|
||||||
|
return 'protocol payload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSnapshot {
|
||||||
|
final DateTime windowStart;
|
||||||
|
final Duration windowDuration;
|
||||||
|
final int packetsPerMinute;
|
||||||
|
final int rxCount;
|
||||||
|
final int txCount;
|
||||||
|
final int totalCount;
|
||||||
|
final double? avgSnrDb;
|
||||||
|
final double? latestSnrDb;
|
||||||
|
final double? avgRssiDbm;
|
||||||
|
final int? latestRssiDbm;
|
||||||
|
final int multiHopCount;
|
||||||
|
final double? avgHopCount;
|
||||||
|
final List<LiveTrafficEntry> visibleEntries;
|
||||||
|
final LiveTrafficBusyness busyness;
|
||||||
|
|
||||||
|
const LiveTrafficSnapshot({
|
||||||
|
required this.windowStart,
|
||||||
|
required this.windowDuration,
|
||||||
|
required this.packetsPerMinute,
|
||||||
|
required this.rxCount,
|
||||||
|
required this.txCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.avgSnrDb,
|
||||||
|
required this.latestSnrDb,
|
||||||
|
required this.avgRssiDbm,
|
||||||
|
required this.latestRssiDbm,
|
||||||
|
required this.multiHopCount,
|
||||||
|
required this.avgHopCount,
|
||||||
|
required this.visibleEntries,
|
||||||
|
required this.busyness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSummary {
|
||||||
|
static const Duration rollingWindow = Duration(seconds: 60);
|
||||||
|
static const int maxVisibleEntries = 120;
|
||||||
|
static const int logRxDataResponseCode = 0x88;
|
||||||
|
|
||||||
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static LiveTrafficSnapshot fromLogs(
|
||||||
|
Iterable<BlePacketLog> logs, {
|
||||||
|
required DateTime now,
|
||||||
|
DateTime? clearedAt,
|
||||||
|
int? preferredHashSize,
|
||||||
|
Duration window = rollingWindow,
|
||||||
|
String? packetTypeFilter,
|
||||||
|
}) {
|
||||||
|
final windowStart = now.subtract(window);
|
||||||
|
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||||
|
? clearedAt
|
||||||
|
: windowStart;
|
||||||
|
|
||||||
|
final recentLogs = logs
|
||||||
|
.where(
|
||||||
|
(log) =>
|
||||||
|
log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode &&
|
||||||
|
!log.timestamp.isBefore(effectiveStart),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
final entries = <LiveTrafficEntry>[];
|
||||||
|
for (final log in recentLogs) {
|
||||||
|
final route = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: preferredHashSize,
|
||||||
|
);
|
||||||
|
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredEntries = packetTypeFilter == null
|
||||||
|
? entries
|
||||||
|
: entries
|
||||||
|
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var rxCount = 0;
|
||||||
|
var snrCount = 0;
|
||||||
|
var snrSum = 0.0;
|
||||||
|
var rssiCount = 0;
|
||||||
|
var rssiSum = 0.0;
|
||||||
|
double? latestSnrDb;
|
||||||
|
int? latestRssiDbm;
|
||||||
|
var multiHopCount = 0;
|
||||||
|
var hopCountTotal = 0;
|
||||||
|
var hopCountSamples = 0;
|
||||||
|
|
||||||
|
for (final entry in filteredEntries) {
|
||||||
|
rxCount += 1;
|
||||||
|
|
||||||
|
final rxInfo = entry.log.logRxDataInfo;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
snrCount += 1;
|
||||||
|
snrSum += rxInfo!.snrDb!;
|
||||||
|
latestSnrDb = rxInfo.snrDb!;
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
rssiCount += 1;
|
||||||
|
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||||
|
latestRssiDbm = rxInfo.rssiDbm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final route = entry.route;
|
||||||
|
if (route != null && route.hopCount > 0) {
|
||||||
|
hopCountSamples += 1;
|
||||||
|
hopCountTotal += route.hopCount;
|
||||||
|
if (route.hopCount > 1) {
|
||||||
|
multiHopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
|
const txCount = 0;
|
||||||
|
final totalCount = rxCount;
|
||||||
|
final packetsPerMinute =
|
||||||
|
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||||
|
|
||||||
|
return LiveTrafficSnapshot(
|
||||||
|
windowStart: effectiveStart,
|
||||||
|
windowDuration: window,
|
||||||
|
packetsPerMinute: packetsPerMinute,
|
||||||
|
rxCount: rxCount,
|
||||||
|
txCount: txCount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||||
|
latestSnrDb: latestSnrDb,
|
||||||
|
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||||
|
latestRssiDbm: latestRssiDbm,
|
||||||
|
multiHopCount: multiHopCount,
|
||||||
|
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
|
||||||
|
visibleEntries: visibleEntries,
|
||||||
|
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||||
|
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||||
|
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||||
|
return LiveTrafficBusyness.busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ class LocalePreferences {
|
|||||||
Locale('fr'), // French
|
Locale('fr'), // French
|
||||||
Locale('it'), // Italian
|
Locale('it'), // Italian
|
||||||
Locale('el'), // Greek
|
Locale('el'), // Greek
|
||||||
|
Locale('pt'), // Portuguese
|
||||||
|
Locale('tr'), // Turkish
|
||||||
|
Locale('pl'), // Polish
|
||||||
|
Locale('uk'), // Ukrainian
|
||||||
Locale('ru'), // Russian
|
Locale('ru'), // Russian
|
||||||
Locale('zh'), // Chinese
|
Locale('zh'), // Chinese
|
||||||
];
|
];
|
||||||
@@ -66,6 +70,14 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
|
case 'pt':
|
||||||
|
return 'Portuguese';
|
||||||
|
case 'tr':
|
||||||
|
return 'Turkish';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polish';
|
||||||
|
case 'uk':
|
||||||
|
return 'Ukrainian';
|
||||||
case 'ru':
|
case 'ru':
|
||||||
return 'Русский';
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
@@ -94,6 +106,14 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
|
case 'pt':
|
||||||
|
return 'Português';
|
||||||
|
case 'tr':
|
||||||
|
return 'Türkçe';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polski';
|
||||||
|
case 'uk':
|
||||||
|
return 'Українська';
|
||||||
case 'ru':
|
case 'ru':
|
||||||
return 'Русский';
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -148,6 +149,10 @@ class LocationTrackingService {
|
|||||||
/// Check if location permissions are granted
|
/// Check if location permissions are granted
|
||||||
Future<bool> checkPermissions() async {
|
Future<bool> checkPermissions() async {
|
||||||
final permission = await Geolocator.checkPermission();
|
final permission = await Geolocator.checkPermission();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return permission == LocationPermission.always;
|
||||||
|
}
|
||||||
|
|
||||||
return permission == LocationPermission.always ||
|
return permission == LocationPermission.always ||
|
||||||
permission == LocationPermission.whileInUse;
|
permission == LocationPermission.whileInUse;
|
||||||
}
|
}
|
||||||
@@ -181,6 +186,16 @@ class LocationTrackingService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
onError?.call(
|
||||||
|
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -209,9 +224,9 @@ class LocationTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
timeLimit: timeLimit,
|
timeLimit: timeLimit,
|
||||||
|
distanceFilter: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -255,13 +270,35 @@ class LocationTrackingService {
|
|||||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||||
return Geolocator.getPositionStream(
|
return Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceFilter.toInt(),
|
distanceFilter: distanceFilter.toInt(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({
|
||||||
|
int distanceFilter = 10,
|
||||||
|
Duration? timeLimit,
|
||||||
|
}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tracking Control
|
// Tracking Control
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -42,89 +42,100 @@ class MapMarkerService {
|
|||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
Position? userPosition,
|
Position? userPosition,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
final location = contact.displayLocation;
|
.map((contact) {
|
||||||
if (location == null) return null;
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) return null;
|
||||||
|
|
||||||
return Marker(
|
return Marker(
|
||||||
point: location,
|
point: location,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotate: false, // Don't rotate the entire marker with map
|
rotate: false, // Don't rotate the entire marker with map
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: -mapRotation * pi / 180,
|
angle: -mapRotation * pi / 180,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap != null ? () => onTap(contact) : null,
|
onTap: onTap != null ? () => onTap(contact) : null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 4,
|
||||||
color: getLocationAgeColor(contact),
|
vertical: 1,
|
||||||
borderRadius: BorderRadius.circular(3),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: getLocationAgeColor(contact),
|
||||||
contact.timeSinceLocationUpdate,
|
borderRadius: BorderRadius.circular(3),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: Colors.white,
|
child: Text(
|
||||||
fontSize: 9,
|
contact.timeSinceLocationUpdate,
|
||||||
fontWeight: FontWeight.bold,
|
style: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
),
|
fontSize: 9,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
// Marker icon
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BoxShape.rectangle
|
|
||||||
: BoxShape.circle,
|
|
||||||
borderRadius: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BorderRadius.circular(14)
|
|
||||||
: null,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: ContactAvatar(contact: contact, radius: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
// Name label (without emoji)
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 80),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 2),
|
||||||
maxLines: 1,
|
// Marker icon
|
||||||
textAlign: TextAlign.center,
|
Container(
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BoxShape.rectangle
|
||||||
|
: BoxShape.circle,
|
||||||
|
borderRadius:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BorderRadius.circular(14)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ContactAvatar(contact: contact, radius: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Name label (without emoji)
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
})
|
||||||
);
|
.whereType<Marker>()
|
||||||
}).whereType<Marker>().toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate markers for SAR events.
|
/// Generate markers for SAR events.
|
||||||
@@ -157,7 +168,10 @@ class MapMarkerService {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getSarMarkerColor(marker.type),
|
color: getSarMarkerColor(marker.type),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -188,7 +202,7 @@ class MapMarkerService {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.emoji, // Use custom emoji if available
|
marker.emoji, // Use custom emoji if available
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,13 +210,17 @@ class MapMarkerService {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
marker
|
||||||
|
.displayName, // Uses notes if available, otherwise type.displayName
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -269,7 +287,8 @@ class MapMarkerService {
|
|||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -299,8 +318,8 @@ class MapMarkerService {
|
|||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -368,6 +387,8 @@ class MapMarkerService {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -389,6 +410,8 @@ class MapMarkerService {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -466,7 +489,8 @@ class MapMarkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allPoints.isEmpty) {
|
if (allPoints.isEmpty) {
|
||||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
return defaultCenter ??
|
||||||
|
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = 0, lng = 0;
|
double lat = 0, lng = 0;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MeshMapNode {
|
class MeshMapNode {
|
||||||
final int type;
|
final int type;
|
||||||
@@ -33,25 +35,36 @@ class MeshMapNode {
|
|||||||
class MeshMapNodesService {
|
class MeshMapNodesService {
|
||||||
static const String _nodesEndpoint =
|
static const String _nodesEndpoint =
|
||||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
static const int repeaterType = 1;
|
||||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
static const Duration _cacheTtl = Duration(hours: 24);
|
||||||
|
static const Duration traceCacheTtl = _cacheTtl;
|
||||||
static const Duration traceTimeout = Duration(seconds: 30);
|
static const Duration traceTimeout = Duration(seconds: 30);
|
||||||
|
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||||
|
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
static Future<void>? _ongoingSync;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
Duration cacheTtl = _cacheTtl,
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
bool allowNetwork = true,
|
||||||
|
http.Client? client,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
cached.isNotEmpty &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
if (!allowNetwork) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await (client ?? http.Client())
|
||||||
.get(Uri.parse(_nodesEndpoint))
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
.timeout(traceTimeout);
|
.timeout(traceTimeout);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
@@ -69,8 +82,133 @@ class MeshMapNodesService {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
await _storeCache(nodes, cachedAt: now);
|
||||||
_cachedAt = now;
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
final cachedJson = prefs.getString(_cacheKey);
|
||||||
|
if (cachedAtMs == null || cachedJson == null) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
if (now.difference(cachedAt) >= cacheTtl) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||||
|
final nodes = decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where(
|
||||||
|
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||||
|
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
return nodes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> syncInBackgroundIfStale({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
if (cached.isNotEmpty && _cachedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ongoingSync != null) {
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ongoingSync = () async {
|
||||||
|
try {
|
||||||
|
await fetchNodes(
|
||||||
|
forceRefresh: true,
|
||||||
|
cacheTtl: cacheTtl,
|
||||||
|
allowNetwork: true,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Background refresh is best-effort only.
|
||||||
|
} finally {
|
||||||
|
_ongoingSync = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
await prefs.remove(_cacheTimestampKey);
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> cachedAt() async {
|
||||||
|
if (_cachedAt != null) return _cachedAt;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
if (cachedAtMs == null) return null;
|
||||||
|
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
return _cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
|
||||||
|
|
||||||
|
static Future<void> _storeCache(
|
||||||
|
List<MeshMapNode> nodes, {
|
||||||
|
required DateTime cachedAt,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_cacheKey,
|
||||||
|
jsonEncode(
|
||||||
|
nodes
|
||||||
|
.map(
|
||||||
|
(node) => {
|
||||||
|
'type': node.type,
|
||||||
|
'name': node.name,
|
||||||
|
'public_key': node.publicKey,
|
||||||
|
'latitude': node.latitude,
|
||||||
|
'longitude': node.longitude,
|
||||||
|
'updated_at': node.updatedAtMs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
|
|||||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
||||||
|
|
||||||
/// Destination types
|
/// Destination types
|
||||||
|
static const String destinationTypeAll = 'all';
|
||||||
static const String destinationTypeChannel = 'channel';
|
static const String destinationTypeChannel = 'channel';
|
||||||
static const String destinationTypeContact = 'contact';
|
static const String destinationTypeContact = 'contact';
|
||||||
static const String destinationTypeRoom = 'room';
|
static const String destinationTypeRoom = 'room';
|
||||||
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
|
|||||||
|
|
||||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||||
|
|
||||||
return {
|
return {'type': type, 'publicKey': ?publicKey};
|
||||||
'type': type,
|
|
||||||
'publicKey': ?publicKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the selected destination
|
/// Save the selected destination
|
||||||
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
||||||
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
||||||
static Future<void> setDestination(
|
static Future<void> setDestination(
|
||||||
String type, {
|
String type, {
|
||||||
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
|
|||||||
/// Get display name for destination type
|
/// Get display name for destination type
|
||||||
static String getDestinationTypeName(String type) {
|
static String getDestinationTypeName(String type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case destinationTypeAll:
|
||||||
|
return 'All';
|
||||||
case destinationTypeChannel:
|
case destinationTypeChannel:
|
||||||
return 'Channel';
|
return 'Channel';
|
||||||
case destinationTypeContact:
|
case destinationTypeContact:
|
||||||
|
|||||||
@@ -5,17 +5,23 @@ import '../models/message.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||||
static const String _messageContactLocationsKey =
|
static const String _messageContactLocationsKey =
|
||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
static const String _messageReceptionDetailsKey =
|
static const String _messageReceptionDetailsKey =
|
||||||
'stored_message_reception_details';
|
'stored_message_reception_details';
|
||||||
static const String _messageTransferDetailsKey =
|
static const String _messageTransferDetailsKey =
|
||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
|
static const String _messageRouteMetadataKey =
|
||||||
|
'stored_message_route_metadata';
|
||||||
|
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||||
|
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
@@ -24,12 +30,21 @@ class MessageStorageService {
|
|||||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||||
|
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
// survive restore even if the sidecar reception-details entry is absent.
|
||||||
|
final jsonList = messages
|
||||||
|
.map(
|
||||||
|
(msg) => _messageToJson(
|
||||||
|
msg,
|
||||||
|
receptionDetails: messageReceptionDetails[msg.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Limit to max stored messages (keep most recent)
|
// Limit to max stored messages (keep most recent)
|
||||||
final limitedList = jsonList.length > _maxStoredMessages
|
final limitedList = jsonList.length > _maxStoredMessages
|
||||||
@@ -44,6 +59,7 @@ class MessageStorageService {
|
|||||||
final locationJson = <String, dynamic>{};
|
final locationJson = <String, dynamic>{};
|
||||||
final receptionJson = <String, dynamic>{};
|
final receptionJson = <String, dynamic>{};
|
||||||
final transferJson = <String, dynamic>{};
|
final transferJson = <String, dynamic>{};
|
||||||
|
final routeMetadataJson = <String, dynamic>{};
|
||||||
for (final entry in messageContactLocations.entries) {
|
for (final entry in messageContactLocations.entries) {
|
||||||
if (retainedMessageIds.contains(entry.key)) {
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
locationJson[entry.key] = entry.value.toJson();
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
@@ -59,6 +75,11 @@ class MessageStorageService {
|
|||||||
transferJson[entry.key] = entry.value.toJson();
|
transferJson[entry.key] = entry.value.toJson();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (final entry in messageRouteMetadata.entries) {
|
||||||
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
|
routeMetadataJson[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_messageContactLocationsKey,
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
@@ -71,6 +92,10 @@ class MessageStorageService {
|
|||||||
_messageTransferDetailsKey,
|
_messageTransferDetailsKey,
|
||||||
jsonEncode(transferJson),
|
jsonEncode(transferJson),
|
||||||
);
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageRouteMetadataKey,
|
||||||
|
jsonEncode(routeMetadataJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -115,24 +140,36 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = <String, MessageReceptionDetails>{};
|
final result = <String, MessageReceptionDetails>{};
|
||||||
for (final entry in decoded.entries) {
|
if (jsonString != null && jsonString.isNotEmpty) {
|
||||||
final value = entry.value;
|
final decoded = jsonDecode(jsonString);
|
||||||
if (value is! Map<String, dynamic>) continue;
|
if (decoded is Map<String, dynamic>) {
|
||||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
for (final entry in decoded.entries) {
|
||||||
if (snapshot != null) {
|
final value = entry.value;
|
||||||
result[entry.key] = snapshot;
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||||
|
if (snapshot != null) {
|
||||||
|
result[entry.key] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
|
||||||
|
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||||
|
result.putIfAbsent(messageId, () => snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
|
||||||
|
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||||
|
result.putIfAbsent(
|
||||||
|
messageId,
|
||||||
|
() => MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||||
@@ -170,6 +207,32 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messageRouteMetadataKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageRouteMetadata>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
result[entry.key] = MessageRouteMetadata.fromJson(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load messages from persistent storage
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -206,12 +269,37 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
|
await prefs.remove(_removedSarMarkerIdsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Set<String>> loadRemovedSarMarkerIds() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
|
||||||
|
return ids.toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||||
|
return const <String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -237,7 +325,12 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Message to JSON
|
/// Convert Message to JSON
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
Map<String, dynamic> _messageToJson(
|
||||||
|
Message message, {
|
||||||
|
MessageReceptionDetails? receptionDetails,
|
||||||
|
}) {
|
||||||
|
final persistedPathBytes =
|
||||||
|
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -246,6 +339,7 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
'channelIdx': message.channelIdx,
|
'channelIdx': message.channelIdx,
|
||||||
'pathLen': message.pathLen,
|
'pathLen': message.pathLen,
|
||||||
|
'pathBytes': persistedPathBytes?.toList(),
|
||||||
'textType': message.textType.value,
|
'textType': message.textType.value,
|
||||||
'senderTimestamp': message.senderTimestamp,
|
'senderTimestamp': message.senderTimestamp,
|
||||||
'text': message.text,
|
'text': message.text,
|
||||||
@@ -297,9 +391,67 @@ class MessageStorageService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (receptionDetails != null)
|
||||||
|
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||||
|
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||||
|
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageReceptionDetails>>
|
||||||
|
_loadEmbeddedReceptionDetails() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||||
|
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||||
|
if (snapshot == null) continue;
|
||||||
|
result[messageId] = snapshot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<int>>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final pathBytes = entry[_legacyPathBytesKey];
|
||||||
|
if (messageId is! String || pathBytes is! List) continue;
|
||||||
|
final normalized = pathBytes
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList();
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
result[messageId] = normalized;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert JSON to Message
|
/// Convert JSON to Message
|
||||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
@@ -316,6 +468,14 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
channelIdx: json['channelIdx'] as int?,
|
channelIdx: json['channelIdx'] as int?,
|
||||||
pathLen: json['pathLen'] as int,
|
pathLen: json['pathLen'] as int,
|
||||||
|
pathBytes: json['pathBytes'] is List
|
||||||
|
? Uint8List.fromList(
|
||||||
|
(json['pathBytes'] as List<dynamic>)
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||||
senderTimestamp: json['senderTimestamp'] as int,
|
senderTimestamp: json['senderTimestamp'] as int,
|
||||||
text: json['text'] as String,
|
text: json['text'] as String,
|
||||||
@@ -413,4 +573,12 @@ class MessageStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||||
|
final pathBytes = receptionDetails?.pathBytes;
|
||||||
|
if (pathBytes == null || pathBytes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(pathBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
lib/services/messaging_route_preferences.dart
Normal file
46
lib/services/messaging_route_preferences.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MessagingRoutePreferences {
|
||||||
|
static const bool defaultAutoRouteRotationEnabled = false;
|
||||||
|
static const bool defaultClearPathOnMaxRetry = false;
|
||||||
|
static const bool defaultNearestRelayFallbackEnabled = true;
|
||||||
|
|
||||||
|
static const String _autoRouteRotationKey =
|
||||||
|
'messaging_auto_route_rotation_enabled';
|
||||||
|
static const String _clearPathOnMaxRetryKey =
|
||||||
|
'messaging_clear_path_on_max_retry';
|
||||||
|
static const String _nearestRelayFallbackKey =
|
||||||
|
'messaging_nearest_relay_fallback_enabled';
|
||||||
|
|
||||||
|
static Future<bool> getAutoRouteRotationEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_autoRouteRotationKey) ??
|
||||||
|
defaultAutoRouteRotationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_autoRouteRotationKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getClearPathOnMaxRetry() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getNearestRelayFallbackEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_nearestRelayFallbackKey) ??
|
||||||
|
defaultNearestRelayFallbackEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_nearestRelayFallbackKey, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/services/nearest_router_selector.dart
Normal file
69
lib/services/nearest_router_selector.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class NearestRouterSelector {
|
||||||
|
const NearestRouterSelector();
|
||||||
|
|
||||||
|
Contact? select({
|
||||||
|
required Position? senderPosition,
|
||||||
|
required List<Contact> repeaters,
|
||||||
|
required Contact recipient,
|
||||||
|
}) {
|
||||||
|
if (senderPosition == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eligible = repeaters.where((contact) {
|
||||||
|
if (contact.publicKeyHex == recipient.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!contact.isRecentlySeen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return contact.displayLocation != null;
|
||||||
|
}).toList();
|
||||||
|
if (eligible.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible.sort((a, b) {
|
||||||
|
final locationA = a.displayLocation!;
|
||||||
|
final locationB = b.displayLocation!;
|
||||||
|
final distanceA = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationA.latitude,
|
||||||
|
locationA.longitude,
|
||||||
|
);
|
||||||
|
final distanceB = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationB.latitude,
|
||||||
|
locationB.longitude,
|
||||||
|
);
|
||||||
|
final distanceCompare = distanceA.compareTo(distanceB);
|
||||||
|
if (distanceCompare != 0) {
|
||||||
|
return distanceCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
|
||||||
|
if (advertCompare != 0) {
|
||||||
|
return advertCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
|
||||||
|
if (hopCompare != 0) {
|
||||||
|
return hopCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameCompare = a.advName.compareTo(b.advName);
|
||||||
|
if (nameCompare != 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return eligible.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:timezone/data/latest_all.dart' as tz;
|
import 'package:timezone/data/latest_all.dart' as tz;
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -13,14 +14,28 @@ class NotificationService {
|
|||||||
|
|
||||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
|
||||||
|
static const String _prefSarEnabled = 'notifications_sar_enabled';
|
||||||
|
static const String _prefDiscoveryEnabled = 'notifications_discovery_enabled';
|
||||||
|
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
|
||||||
|
static const String _prefMuteForeground = 'notifications_mute_foreground';
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _permissionGranted = false;
|
bool _permissionGranted = false;
|
||||||
|
bool _messageNotificationsEnabled = true;
|
||||||
|
bool _sarNotificationsEnabled = true;
|
||||||
|
bool _discoveryNotificationsEnabled = true;
|
||||||
|
bool _updateNotificationsEnabled = true;
|
||||||
|
bool _muteForegroundNotifications = true;
|
||||||
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
String? _launchPayload;
|
||||||
|
|
||||||
// Notification IDs
|
// Notification IDs
|
||||||
static const int _sarNotificationId = 1000;
|
static const int _sarNotificationId = 1000;
|
||||||
static const int _messageNotificationId = 2000;
|
static const int _messageNotificationId = 2000;
|
||||||
static const int _updateNotificationId = 3000;
|
static const int _updateNotificationId = 3000;
|
||||||
|
static const int _batteryNotificationId = 4000;
|
||||||
|
static const int _discoveryNotificationId = 5000;
|
||||||
|
|
||||||
// Notification channels
|
// Notification channels
|
||||||
static const String _urgentChannelId = 'sar_urgent';
|
static const String _urgentChannelId = 'sar_urgent';
|
||||||
@@ -38,12 +53,32 @@ class NotificationService {
|
|||||||
static const String _updateChannelDescription =
|
static const String _updateChannelDescription =
|
||||||
'Notifications for available app updates';
|
'Notifications for available app updates';
|
||||||
|
|
||||||
|
static const String _batteryChannelId = 'battery_alerts';
|
||||||
|
static const String _batteryChannelName = 'Battery Alerts';
|
||||||
|
static const String _batteryChannelDescription =
|
||||||
|
'Notifications when a device or contact battery is low';
|
||||||
|
|
||||||
|
static const String _discoveryChannelId = 'discovery_alerts';
|
||||||
|
static const String _discoveryChannelName = 'Discovery Alerts';
|
||||||
|
static const String _discoveryChannelDescription =
|
||||||
|
'Notifications when new contacts are discovered';
|
||||||
|
|
||||||
|
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
|
||||||
|
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
|
||||||
|
bool get discoveryNotificationsEnabled => _discoveryNotificationsEnabled;
|
||||||
|
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
|
||||||
|
bool get muteForegroundNotifications => _muteForegroundNotifications;
|
||||||
|
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
|
||||||
|
|
||||||
/// Initialize notification service
|
/// Initialize notification service
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('📬 [NotificationService] Initializing...');
|
debugPrint('📬 [NotificationService] Initializing...');
|
||||||
|
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
|
||||||
|
_lifecycleState =
|
||||||
|
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
|
||||||
|
|
||||||
// Initialize timezone data
|
// Initialize timezone data
|
||||||
tz.initializeTimeZones();
|
tz.initializeTimeZones();
|
||||||
@@ -72,8 +107,15 @@ class NotificationService {
|
|||||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final launchDetails = await _notificationsPlugin
|
||||||
|
.getNotificationAppLaunchDetails();
|
||||||
|
if (launchDetails?.didNotificationLaunchApp ?? false) {
|
||||||
|
_launchPayload = launchDetails?.notificationResponse?.payload;
|
||||||
|
}
|
||||||
|
|
||||||
// Request permissions
|
// Request permissions
|
||||||
await _requestPermissions();
|
await _requestPermissions();
|
||||||
|
await _loadPreferences();
|
||||||
|
|
||||||
// Create notification channels (Android)
|
// Create notification channels (Android)
|
||||||
await _createNotificationChannels();
|
await _createNotificationChannels();
|
||||||
@@ -132,6 +174,54 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
|
||||||
|
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
|
||||||
|
_discoveryNotificationsEnabled =
|
||||||
|
prefs.getBool(_prefDiscoveryEnabled) ?? true;
|
||||||
|
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
|
||||||
|
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMessageNotificationsEnabled(bool value) async {
|
||||||
|
_messageNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefMessagesEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSarNotificationsEnabled(bool value) async {
|
||||||
|
_sarNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefSarEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setDiscoveryNotificationsEnabled(bool value) async {
|
||||||
|
_discoveryNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefDiscoveryEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setUpdateNotificationsEnabled(bool value) async {
|
||||||
|
_updateNotificationsEnabled = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefUpdatesEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMuteForegroundNotifications(bool value) async {
|
||||||
|
_muteForegroundNotifications = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_prefMuteForeground, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleAppLifecycleStateChanged(AppLifecycleState state) {
|
||||||
|
_lifecycleState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldSuppressForegroundNotifications() {
|
||||||
|
return _muteForegroundNotifications && isAppInForeground;
|
||||||
|
}
|
||||||
|
|
||||||
/// Create notification channels for Android
|
/// Create notification channels for Android
|
||||||
Future<void> _createNotificationChannels() async {
|
Future<void> _createNotificationChannels() async {
|
||||||
try {
|
try {
|
||||||
@@ -177,9 +267,31 @@ class NotificationService {
|
|||||||
showBadge: true,
|
showBadge: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const batteryChannel = AndroidNotificationChannel(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
description: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const discoveryChannel = AndroidNotificationChannel(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
description: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
await androidPlugin.createNotificationChannel(urgentChannel);
|
await androidPlugin.createNotificationChannel(urgentChannel);
|
||||||
await androidPlugin.createNotificationChannel(messagesChannel);
|
await androidPlugin.createNotificationChannel(messagesChannel);
|
||||||
await androidPlugin.createNotificationChannel(updateChannel);
|
await androidPlugin.createNotificationChannel(updateChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(batteryChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(discoveryChannel);
|
||||||
debugPrint('✅ [NotificationService] Created notification channels');
|
debugPrint('✅ [NotificationService] Created notification channels');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
||||||
@@ -201,6 +313,12 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? consumeLaunchPayload() {
|
||||||
|
final payload = _launchPayload;
|
||||||
|
_launchPayload = null;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
/// Show urgent notification for SAR marker
|
/// Show urgent notification for SAR marker
|
||||||
Future<void> showSarNotification({
|
Future<void> showSarNotification({
|
||||||
required SarMarkerType type,
|
required SarMarkerType type,
|
||||||
@@ -222,6 +340,16 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_sarNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] SAR notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping SAR notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique notification ID based on timestamp
|
// Generate unique notification ID based on timestamp
|
||||||
@@ -394,6 +522,16 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_messageNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping message notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate unique notification ID based on timestamp
|
// Generate unique notification ID based on timestamp
|
||||||
@@ -402,10 +540,13 @@ class NotificationService {
|
|||||||
(DateTime.now().millisecondsSinceEpoch % 1000);
|
(DateTime.now().millisecondsSinceEpoch % 1000);
|
||||||
|
|
||||||
// Build notification title and body
|
// Build notification title and body
|
||||||
|
final resolvedChannelName = channelName?.trim().isNotEmpty == true
|
||||||
|
? channelName!.trim()
|
||||||
|
: (localizations?.publicChannel ?? 'Public');
|
||||||
final title = isChannelMessage
|
final title = isChannelMessage
|
||||||
? (localizations != null
|
? (localizations != null
|
||||||
? '${localizations.channel}: ${channelName ?? "Public"}'
|
? '${localizations.channel}: $resolvedChannelName'
|
||||||
: 'Channel: ${channelName ?? "Public"}')
|
: 'Channel: $resolvedChannelName')
|
||||||
: (localizations != null
|
: (localizations != null
|
||||||
? '${localizations.newMessage} ${localizations.from} $senderName'
|
? '${localizations.newMessage} ${localizations.from} $senderName'
|
||||||
: 'New message from $senderName');
|
: 'New message from $senderName');
|
||||||
@@ -544,13 +685,23 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_updateNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Update notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping update notification',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build notification title and body
|
// Build notification title and body
|
||||||
final title = localizations?.updateAvailable ?? 'App Update Available';
|
final title = localizations?.updateAvailable ?? 'App Update Available';
|
||||||
final body = localizations != null
|
final body = localizations != null
|
||||||
? '${localizations.currentVersion}: $currentVersion\n'
|
? '${localizations.currentVersion}: $currentVersion\n'
|
||||||
'${localizations.latestVersion}: $latestVersion'
|
'${localizations.latestVersion}: $latestVersion'
|
||||||
: 'Current: $currentVersion\nLatest: $latestVersion';
|
: 'Current: $currentVersion\nLatest: $latestVersion';
|
||||||
|
|
||||||
// Android notification details
|
// Android notification details
|
||||||
@@ -614,4 +765,169 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> showLowBatteryNotification({
|
||||||
|
required String nodeId,
|
||||||
|
required String nodeName,
|
||||||
|
required double batteryPercent,
|
||||||
|
required bool isCurrentDevice,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_permissionGranted) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!_messageNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping low battery notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final roundedPercent = batteryPercent.round().clamp(0, 100);
|
||||||
|
final title = isCurrentDevice
|
||||||
|
? 'Device battery low'
|
||||||
|
: 'Contact battery low';
|
||||||
|
final body = isCurrentDevice
|
||||||
|
? '$nodeName battery is at $roundedPercent%.'
|
||||||
|
: '$nodeName is at $roundedPercent% battery.';
|
||||||
|
final notificationId =
|
||||||
|
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
channelDescription: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
styleInformation: BigTextStyleInformation(
|
||||||
|
body,
|
||||||
|
contentTitle: title,
|
||||||
|
summaryText: '$roundedPercent%',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'battery_alerts',
|
||||||
|
subtitle: '$roundedPercent%',
|
||||||
|
);
|
||||||
|
|
||||||
|
final notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: notificationDetails,
|
||||||
|
payload: 'battery:$nodeId',
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing low battery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showContactDiscoveredNotification({
|
||||||
|
required String contactKey,
|
||||||
|
String? contactName,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) return false;
|
||||||
|
if (!_permissionGranted) return false;
|
||||||
|
if (!_discoveryNotificationsEnabled) return false;
|
||||||
|
if (_shouldSuppressForegroundNotifications()) return false;
|
||||||
|
|
||||||
|
final shortKey = contactKey.length > 12
|
||||||
|
? contactKey.substring(0, 12).toUpperCase()
|
||||||
|
: contactKey.toUpperCase();
|
||||||
|
final title = 'New contact discovered';
|
||||||
|
final resolvedName = contactName?.trim();
|
||||||
|
final body = resolvedName != null && resolvedName.isNotEmpty
|
||||||
|
? '$resolvedName is available in Discovery.'
|
||||||
|
: 'New contact $shortKey is available in Discovery.';
|
||||||
|
final notificationId =
|
||||||
|
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
channelDescription: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'discovery_alerts',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
),
|
||||||
|
payload: 'discovery:$contactKey',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing discovery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LifecycleObserver with WidgetsBindingObserver {
|
||||||
|
_LifecycleObserver(this._service);
|
||||||
|
|
||||||
|
final NotificationService _service;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_service.handleAppLifecycleStateChanged(state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
|||||||
File? _file;
|
File? _file;
|
||||||
|
|
||||||
Future<File> _resolveFile() async {
|
Future<File> _resolveFile() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||||
|
}
|
||||||
if (_file != null) return _file!;
|
if (_file != null) return _file!;
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final file = File('${dir.path}/$_fileName');
|
final file = File('${dir.path}/$_fileName');
|
||||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
|||||||
|
|
||||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||||
if (logs.isEmpty) return;
|
if (logs.isEmpty) return;
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
final sink = file.openWrite(mode: FileMode.append);
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||||
|
if (kIsWeb) return const [];
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return const [];
|
if (!await file.exists()) return const [];
|
||||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> count() async {
|
Future<int> count() async {
|
||||||
|
if (kIsWeb) return 0;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return 0;
|
if (!await file.exists()) return 0;
|
||||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
282
lib/services/path_history_service.dart
Normal file
282
lib/services/path_history_service.dart
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_history.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
class PathHistoryService {
|
||||||
|
static const String _storageKey = 'contact_path_history_v1';
|
||||||
|
static const int _maxDirectPaths = 20;
|
||||||
|
static const int _topRotationCount = 3;
|
||||||
|
|
||||||
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isLoaded) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_storageKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
_isLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
|
||||||
|
}
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordLearnedPath(Contact contact) async {
|
||||||
|
await initialize();
|
||||||
|
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final signature = _signature(contact.routePathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
|
successCount: existing?.successCount ?? 0,
|
||||||
|
failureCount: existing?.failureCount ?? 0,
|
||||||
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveHistory(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordReceivedBytePath(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
List<int> pathBytes,
|
||||||
|
int hashSize,
|
||||||
|
) async {
|
||||||
|
await initialize();
|
||||||
|
if (pathBytes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hashSize < 1 || hashSize > 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathBytes.length % hashSize != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
|
final signature = normalizedPathBytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: normalizedPathBytes,
|
||||||
|
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||||
|
hashSize: hashSize,
|
||||||
|
source: PathRecordSource.observed,
|
||||||
|
successCount: existing?.successCount ?? 0,
|
||||||
|
failureCount: existing?.failureCount ?? 0,
|
||||||
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PathSelection> getSelectionForContact(
|
||||||
|
Contact contact, {
|
||||||
|
required bool autoRouteRotationEnabled,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
await recordLearnedPath(contact);
|
||||||
|
|
||||||
|
if (contact.routeHasPath && contact.routeHopCount > 0) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: PathSelectionMode.directCurrent,
|
||||||
|
pathBytes: Uint8List.fromList(contact.routePathBytes),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoRouteRotationEnabled) {
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final ranked = List<PathRecord>.from(history.directPaths)
|
||||||
|
..sort(_comparePathRecords);
|
||||||
|
final topPaths = ranked.take(_topRotationCount).toList();
|
||||||
|
if (topPaths.isEmpty) {
|
||||||
|
final nextFloodHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final selections =
|
||||||
|
topPaths
|
||||||
|
.map(
|
||||||
|
(record) => PathSelection(
|
||||||
|
mode: PathSelectionMode.directHistorical,
|
||||||
|
pathBytes: Uint8List.fromList(record.pathBytes),
|
||||||
|
hopCount: record.hopCount,
|
||||||
|
hashSize: record.hashSize,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..add(PathSelection.flood());
|
||||||
|
|
||||||
|
final index = history.rotationIndex % selections.length;
|
||||||
|
final updatedHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, updatedHistory);
|
||||||
|
return selections[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordPathResult(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool success,
|
||||||
|
int? roundTripTimeMs,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
|
if (selection.usesFlood) {
|
||||||
|
final current = history.floodStats;
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
floodStats: current.copyWith(
|
||||||
|
successCount: current.successCount + (success ? 1 : 0),
|
||||||
|
failureCount: current.failureCount + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
|
||||||
|
: current.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final signature = _signature(selection.pathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: selection.pathBytes.toList(),
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
hashSize: selection.hashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
|
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
||||||
|
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
|
||||||
|
: (existing?.lastRoundTripTimeMs ?? 0),
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache[contactPublicKeyHex] ??
|
||||||
|
ContactPathHistory.empty(contactPublicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory _historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache.putIfAbsent(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
() => ContactPathHistory.empty(contactPublicKeyHex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHistory(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
ContactPathHistory history,
|
||||||
|
) async {
|
||||||
|
_cache[contactPublicKeyHex] = history;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
for (final entry in _cache.entries) {
|
||||||
|
payload[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
await prefs.setString(_storageKey, jsonEncode(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> _upsertDirectPath(
|
||||||
|
List<PathRecord> existing,
|
||||||
|
PathRecord updatedRecord,
|
||||||
|
) {
|
||||||
|
final updated = List<PathRecord>.from(existing)
|
||||||
|
..removeWhere((record) => record.signature == updatedRecord.signature)
|
||||||
|
..insert(0, updatedRecord);
|
||||||
|
if (updated.length > _maxDirectPaths) {
|
||||||
|
return updated.take(_maxDirectPaths).toList();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _comparePathRecords(PathRecord a, PathRecord b) {
|
||||||
|
final successRateCompare = b.successRate.compareTo(a.successRate);
|
||||||
|
if (successRateCompare != 0) return successRateCompare;
|
||||||
|
|
||||||
|
final successCountCompare = b.successCount.compareTo(a.successCount);
|
||||||
|
if (successCountCompare != 0) return successCountCompare;
|
||||||
|
|
||||||
|
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
|
||||||
|
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
|
||||||
|
final rttCompare = aRtt.compareTo(bRtt);
|
||||||
|
if (rttCompare != 0) return rttCompare;
|
||||||
|
|
||||||
|
return b.lastUsedAt.compareTo(a.lastUsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
|
||||||
|
for (final record in records) {
|
||||||
|
if (record.signature == signature) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature(Uint8List bytes) =>
|
||||||
|
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
@@ -1,785 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:shelf/shelf.dart' as shelf;
|
|
||||||
import 'package:shelf/shelf_io.dart' as io;
|
|
||||||
import 'package:nsd/nsd.dart';
|
|
||||||
import '../models/message.dart';
|
|
||||||
import '../models/contact.dart';
|
|
||||||
import '../models/sse_server_config.dart';
|
|
||||||
import 'network_scanner_service.dart';
|
|
||||||
|
|
||||||
/// SSE Server Service
|
|
||||||
///
|
|
||||||
/// Provides a web server with SSE (Server-Sent Events) endpoints for
|
|
||||||
/// real-time message and contact updates, enabling multiple app instances
|
|
||||||
/// to share a single MeshCore BLE device.
|
|
||||||
///
|
|
||||||
/// Endpoints:
|
|
||||||
/// - GET /sse/messages - SSE stream for message updates
|
|
||||||
/// - GET /sse/contacts - SSE stream for contact updates
|
|
||||||
/// - POST /api/messages - Send message
|
|
||||||
/// - POST /api/messages/channel - Send channel message
|
|
||||||
/// - POST /api/contacts/sync - Trigger contact sync
|
|
||||||
/// - GET /api/messages/history - Get all messages
|
|
||||||
/// - GET /api/contacts - Get all contacts
|
|
||||||
/// - GET /api/status - Server health check
|
|
||||||
class SseServerService {
|
|
||||||
HttpServer? _server;
|
|
||||||
SseServerConfig? _config;
|
|
||||||
Registration? _bonjourRegistration;
|
|
||||||
|
|
||||||
/// Active SSE connections for messages
|
|
||||||
final Set<StreamController<String>> _messageStreams = {};
|
|
||||||
|
|
||||||
/// Active SSE connections for contacts
|
|
||||||
final Set<StreamController<String>> _contactStreams = {};
|
|
||||||
|
|
||||||
/// Message history (for new clients)
|
|
||||||
final List<Message> _messageHistory = [];
|
|
||||||
|
|
||||||
/// Contact list (for new clients)
|
|
||||||
final Map<String, Contact> _contacts = {};
|
|
||||||
|
|
||||||
/// Timer for cleaning up dead connections
|
|
||||||
Timer? _cleanupTimer;
|
|
||||||
|
|
||||||
/// Device name (for status endpoint)
|
|
||||||
String? _deviceName;
|
|
||||||
|
|
||||||
/// Set device name
|
|
||||||
void setDeviceName(String? name) {
|
|
||||||
_deviceName = name;
|
|
||||||
debugPrint('📝 [SseServer] Device name set to: $name');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Callback for when a client requests to send a message
|
|
||||||
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests to send a channel message
|
|
||||||
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
|
|
||||||
|
|
||||||
/// Callback for when a client requests contact sync
|
|
||||||
Future<void> Function()? onSyncContacts;
|
|
||||||
|
|
||||||
/// Check if server is running
|
|
||||||
bool get isRunning => _server != null;
|
|
||||||
|
|
||||||
/// Get current configuration
|
|
||||||
SseServerConfig? get config => _config;
|
|
||||||
|
|
||||||
/// Get number of connected clients
|
|
||||||
int get connectedClients => _messageStreams.length;
|
|
||||||
|
|
||||||
/// CORS middleware
|
|
||||||
static shelf.Middleware get _corsHeaders {
|
|
||||||
return shelf.createMiddleware(
|
|
||||||
responseHandler: (shelf.Response response) {
|
|
||||||
return response.change(
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers':
|
|
||||||
'Origin, Content-Type, Authorization',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the SSE server
|
|
||||||
Future<void> startServer(SseServerConfig config) async {
|
|
||||||
if (_server != null) {
|
|
||||||
debugPrint('⚠️ [SseServer] Server already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_config = config;
|
|
||||||
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create shelf handler with CORS support
|
|
||||||
final handler = const shelf.Pipeline()
|
|
||||||
.addMiddleware(_corsHeaders)
|
|
||||||
.addMiddleware(shelf.logRequests())
|
|
||||||
.addHandler(_handleRequest);
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
_server = await io.serve(handler, config.host, config.port);
|
|
||||||
|
|
||||||
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
|
|
||||||
|
|
||||||
// Start cleanup timer for dead connections
|
|
||||||
_startCleanupTimer();
|
|
||||||
|
|
||||||
// Register Bonjour/mDNS service
|
|
||||||
await _registerBonjourService(config);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Failed to start server: $e');
|
|
||||||
_server = null;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register Bonjour/mDNS service for network discovery
|
|
||||||
Future<void> _registerBonjourService(SseServerConfig config) async {
|
|
||||||
try {
|
|
||||||
debugPrint(
|
|
||||||
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
|
|
||||||
);
|
|
||||||
|
|
||||||
_bonjourRegistration = await register(
|
|
||||||
const Service(
|
|
||||||
name: 'MeshCore SSE Server',
|
|
||||||
type: NetworkScannerService.serviceType,
|
|
||||||
port: 0, // Will be set dynamically
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update with actual port
|
|
||||||
if (_bonjourRegistration != null) {
|
|
||||||
// Unregister and re-register with correct port
|
|
||||||
await unregister(_bonjourRegistration!);
|
|
||||||
_bonjourRegistration = await register(
|
|
||||||
Service(
|
|
||||||
name: 'MeshCore SSE Server',
|
|
||||||
type: NetworkScannerService.serviceType,
|
|
||||||
port: config.port,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
'✅ [SseServer] Bonjour service registered on port ${config.port}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
|
|
||||||
// Don't throw - server can still work without Bonjour
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start cleanup timer to remove dead connections
|
|
||||||
void _startCleanupTimer() {
|
|
||||||
_cleanupTimer?.cancel();
|
|
||||||
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
|
|
||||||
_cleanupDeadConnections();
|
|
||||||
});
|
|
||||||
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up dead/closed connections
|
|
||||||
void _cleanupDeadConnections() {
|
|
||||||
// Clean up message streams
|
|
||||||
final deadMessageStreams = _messageStreams
|
|
||||||
.where((s) => s.isClosed)
|
|
||||||
.toList();
|
|
||||||
for (final stream in deadMessageStreams) {
|
|
||||||
_messageStreams.remove(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up contact streams
|
|
||||||
final deadContactStreams = _contactStreams
|
|
||||||
.where((s) => s.isClosed)
|
|
||||||
.toList();
|
|
||||||
for (final stream in deadContactStreams) {
|
|
||||||
_contactStreams.remove(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
|
|
||||||
);
|
|
||||||
debugPrint(
|
|
||||||
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the SSE server
|
|
||||||
Future<void> stopServer() async {
|
|
||||||
if (_server == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🛑 [SseServer] Stopping server...');
|
|
||||||
|
|
||||||
// Stop cleanup timer
|
|
||||||
_cleanupTimer?.cancel();
|
|
||||||
_cleanupTimer = null;
|
|
||||||
|
|
||||||
// Close all SSE streams
|
|
||||||
for (final stream in _messageStreams) {
|
|
||||||
await stream.close();
|
|
||||||
}
|
|
||||||
_messageStreams.clear();
|
|
||||||
|
|
||||||
for (final stream in _contactStreams) {
|
|
||||||
await stream.close();
|
|
||||||
}
|
|
||||||
_contactStreams.clear();
|
|
||||||
|
|
||||||
// Unregister Bonjour service
|
|
||||||
if (_bonjourRegistration != null) {
|
|
||||||
try {
|
|
||||||
await unregister(_bonjourRegistration!);
|
|
||||||
debugPrint('✅ [SseServer] Bonjour service unregistered');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
|
|
||||||
}
|
|
||||||
_bonjourRegistration = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close HTTP server
|
|
||||||
await _server!.close(force: true);
|
|
||||||
_server = null;
|
|
||||||
_config = null;
|
|
||||||
|
|
||||||
debugPrint('✅ [SseServer] Server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Main request handler
|
|
||||||
Future<shelf.Response> _handleRequest(shelf.Request request) async {
|
|
||||||
// Check authentication if token is configured
|
|
||||||
if (_config?.authToken != null) {
|
|
||||||
final authHeader = request.headers['authorization'];
|
|
||||||
if (authHeader != 'Bearer ${_config!.authToken}') {
|
|
||||||
return shelf.Response.forbidden('Invalid authentication token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final path = request.url.path;
|
|
||||||
final method = request.method;
|
|
||||||
|
|
||||||
debugPrint('📨 [SseServer] $method /$path');
|
|
||||||
|
|
||||||
// Route requests
|
|
||||||
if (method == 'GET' && path == 'sse/messages') {
|
|
||||||
return _handleSseMessages(request);
|
|
||||||
} else if (method == 'GET' && path == 'sse/contacts') {
|
|
||||||
return _handleSseContacts(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/messages') {
|
|
||||||
return _handlePostMessage(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/messages/channel') {
|
|
||||||
return _handlePostChannelMessage(request);
|
|
||||||
} else if (method == 'POST' && path == 'api/contacts/sync') {
|
|
||||||
return _handlePostContactsSync(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/messages/history') {
|
|
||||||
return _handleGetMessageHistory(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/contacts') {
|
|
||||||
return _handleGetContacts(request);
|
|
||||||
} else if (method == 'GET' && path == 'api/status') {
|
|
||||||
return _handleGetStatus(request);
|
|
||||||
} else if (method == 'GET' && path == '') {
|
|
||||||
return _handleRoot(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
return shelf.Response.notFound('Not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle SSE messages stream
|
|
||||||
shelf.Response _handleSseMessages(shelf.Request request) {
|
|
||||||
return request.hijack((channel) async {
|
|
||||||
debugPrint(
|
|
||||||
'📥 [SseServer] New SSE client connected (messages) via hijack',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up the sink for sending data
|
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
|
||||||
|
|
||||||
// Send SSE headers
|
|
||||||
sink.add('HTTP/1.1 200 OK\r\n');
|
|
||||||
sink.add('Content-Type: text/event-stream\r\n');
|
|
||||||
sink.add('Cache-Control: no-cache\r\n');
|
|
||||||
sink.add('Connection: keep-alive\r\n');
|
|
||||||
sink.add('\r\n');
|
|
||||||
|
|
||||||
// Create controller for this connection
|
|
||||||
final controller = StreamController<String>();
|
|
||||||
_messageStreams.add(controller);
|
|
||||||
|
|
||||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
sink.add(': connected\n\n');
|
|
||||||
|
|
||||||
// Send initial message history
|
|
||||||
for (final message in _messageHistory) {
|
|
||||||
final event = _formatSseEvent('message', _messageToJson(message));
|
|
||||||
sink.add(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start keep-alive timer
|
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
|
||||||
timer,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
sink.add(': keepalive\n\n');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to controller for new messages to broadcast
|
|
||||||
final subscription = controller.stream.listen(
|
|
||||||
(data) {
|
|
||||||
try {
|
|
||||||
sink.add(data);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint('📤 [SseServer] Controller stream closed');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for channel to close
|
|
||||||
await channel.stream.drain();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
keepAliveTimer.cancel();
|
|
||||||
await subscription.cancel();
|
|
||||||
_messageStreams.remove(controller);
|
|
||||||
await controller.close();
|
|
||||||
|
|
||||||
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
|
|
||||||
debugPrint(' Total clients: ${_messageStreams.length}');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle SSE contacts stream
|
|
||||||
shelf.Response _handleSseContacts(shelf.Request request) {
|
|
||||||
return request.hijack((channel) async {
|
|
||||||
debugPrint(
|
|
||||||
'📥 [SseServer] New SSE client connected (contacts) via hijack',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up the sink for sending data
|
|
||||||
final sink = utf8.encoder.startChunkedConversion(channel.sink);
|
|
||||||
|
|
||||||
// Send SSE headers
|
|
||||||
sink.add('HTTP/1.1 200 OK\r\n');
|
|
||||||
sink.add('Content-Type: text/event-stream\r\n');
|
|
||||||
sink.add('Cache-Control: no-cache\r\n');
|
|
||||||
sink.add('Connection: keep-alive\r\n');
|
|
||||||
sink.add('\r\n');
|
|
||||||
|
|
||||||
// Create controller for this connection
|
|
||||||
final controller = StreamController<String>();
|
|
||||||
_contactStreams.add(controller);
|
|
||||||
|
|
||||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
sink.add(': connected\n\n');
|
|
||||||
|
|
||||||
// Send initial contact list
|
|
||||||
for (final contact in _contacts.values) {
|
|
||||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
|
||||||
sink.add(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start keep-alive timer
|
|
||||||
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
|
|
||||||
timer,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
sink.add(': keepalive\n\n');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
|
|
||||||
timer.cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to controller for new messages to broadcast
|
|
||||||
final subscription = controller.stream.listen(
|
|
||||||
(data) {
|
|
||||||
try {
|
|
||||||
sink.add(data);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('⚠️ [SseServer] Failed to send data: $e');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
debugPrint('📤 [SseServer] Controller stream closed');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for channel to close
|
|
||||||
await channel.stream.drain();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
keepAliveTimer.cancel();
|
|
||||||
await subscription.cancel();
|
|
||||||
_contactStreams.remove(controller);
|
|
||||||
await controller.close();
|
|
||||||
|
|
||||||
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
|
|
||||||
debugPrint(' Total clients: ${_contactStreams.length}');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST message request
|
|
||||||
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final recipientPublicKey = json['recipientPublicKey'] as String;
|
|
||||||
final text = json['text'] as String;
|
|
||||||
|
|
||||||
if (onSendMessage == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': 'Send message callback not configured'}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await onSendMessage!(recipientPublicKey, text);
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': success}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST message: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST channel message request
|
|
||||||
Future<shelf.Response> _handlePostChannelMessage(
|
|
||||||
shelf.Request request,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final body = await request.readAsString();
|
|
||||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
final channelIdx = json['channelIdx'] as int;
|
|
||||||
final text = json['text'] as String;
|
|
||||||
|
|
||||||
if (onSendChannelMessage == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({
|
|
||||||
'error': 'Send channel message callback not configured',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSendChannelMessage!(channelIdx, text);
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': true}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle POST contacts sync request
|
|
||||||
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
|
|
||||||
try {
|
|
||||||
if (onSyncContacts == null) {
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await onSyncContacts!();
|
|
||||||
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'success': true}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
|
|
||||||
return shelf.Response.internalServerError(
|
|
||||||
body: jsonEncode({'error': e.toString()}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET message history request
|
|
||||||
shelf.Response _handleGetMessageHistory(shelf.Request request) {
|
|
||||||
final messages = _messageHistory.map(_messageToJson).toList();
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'messages': messages}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET contacts request
|
|
||||||
shelf.Response _handleGetContacts(shelf.Request request) {
|
|
||||||
final contacts = _contacts.values.map(_contactToJson).toList();
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({'contacts': contacts}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle GET status request
|
|
||||||
shelf.Response _handleGetStatus(shelf.Request request) {
|
|
||||||
return shelf.Response.ok(
|
|
||||||
jsonEncode({
|
|
||||||
'status': 'running',
|
|
||||||
'connectedClients': connectedClients,
|
|
||||||
'messageCount': _messageHistory.length,
|
|
||||||
'contactCount': _contacts.length,
|
|
||||||
'deviceName': _deviceName,
|
|
||||||
}),
|
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle root request (landing page)
|
|
||||||
shelf.Response _handleRoot(shelf.Request request) {
|
|
||||||
final html = '''
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>MeshCore SAR - SSE Server</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
|
||||||
h1 { color: #333; }
|
|
||||||
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
|
|
||||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
|
|
||||||
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>🚀 MeshCore SAR Server</h1>
|
|
||||||
<div class="status">✅ Server is running</div>
|
|
||||||
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
|
|
||||||
|
|
||||||
<h2>📡 SSE Endpoints</h2>
|
|
||||||
<div class="endpoint">GET /sse/messages</div>
|
|
||||||
<div class="endpoint">GET /sse/contacts</div>
|
|
||||||
|
|
||||||
<h2>🔧 API Endpoints</h2>
|
|
||||||
<div class="endpoint">POST /api/messages</div>
|
|
||||||
<div class="endpoint">POST /api/messages/channel</div>
|
|
||||||
<div class="endpoint">POST /api/contacts/sync</div>
|
|
||||||
<div class="endpoint">GET /api/messages/history</div>
|
|
||||||
<div class="endpoint">GET /api/contacts</div>
|
|
||||||
<div class="endpoint">GET /api/status</div>
|
|
||||||
|
|
||||||
<h2>📊 Stats</h2>
|
|
||||||
<p>Connected clients: <strong id="clients">Loading...</strong></p>
|
|
||||||
<p>Messages: <strong id="messages">Loading...</strong></p>
|
|
||||||
<p>Contacts: <strong id="contacts">Loading...</strong></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function updateStats() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/status');
|
|
||||||
const data = await res.json();
|
|
||||||
document.getElementById('clients').textContent = data.connectedClients;
|
|
||||||
document.getElementById('messages').textContent = data.messageCount;
|
|
||||||
document.getElementById('contacts').textContent = data.contactCount;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch stats:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateStats();
|
|
||||||
setInterval(updateStats, 5000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''';
|
|
||||||
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast a new message to all SSE clients
|
|
||||||
void broadcastMessage(Message message) {
|
|
||||||
// Add to history (limit to 1000 messages)
|
|
||||||
_messageHistory.add(message);
|
|
||||||
if (_messageHistory.length > 1000) {
|
|
||||||
_messageHistory.removeAt(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
|
||||||
final event = _formatSseEvent('message', _messageToJson(message));
|
|
||||||
final deadStreams = <StreamController<String>>[];
|
|
||||||
|
|
||||||
for (final stream in _messageStreams) {
|
|
||||||
if (stream.isClosed) {
|
|
||||||
deadStreams.add(stream);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
stream.add(event);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
|
||||||
);
|
|
||||||
deadStreams.add(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove dead streams
|
|
||||||
for (final stream in deadStreams) {
|
|
||||||
_messageStreams.remove(stream);
|
|
||||||
stream.close().catchError(
|
|
||||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Broadcast a new or updated contact to all SSE clients
|
|
||||||
void broadcastContact(Contact contact) {
|
|
||||||
// Update contact list
|
|
||||||
_contacts[contact.publicKeyHex] = contact;
|
|
||||||
|
|
||||||
// Broadcast to all connected clients
|
|
||||||
final event = _formatSseEvent('contact', _contactToJson(contact));
|
|
||||||
final deadStreams = <StreamController<String>>[];
|
|
||||||
|
|
||||||
for (final stream in _contactStreams) {
|
|
||||||
if (stream.isClosed) {
|
|
||||||
deadStreams.add(stream);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
stream.add(event);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
|
|
||||||
);
|
|
||||||
deadStreams.add(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove dead streams
|
|
||||||
for (final stream in deadStreams) {
|
|
||||||
_contactStreams.remove(stream);
|
|
||||||
stream.close().catchError(
|
|
||||||
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deadStreams.isNotEmpty) {
|
|
||||||
debugPrint(
|
|
||||||
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint(
|
|
||||||
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format SSE event
|
|
||||||
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
|
|
||||||
final jsonData = jsonEncode(data);
|
|
||||||
return 'event: $eventType\ndata: $jsonData\n\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Message to JSON
|
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
|
||||||
return {
|
|
||||||
'id': message.id,
|
|
||||||
'messageType': message.messageType.name,
|
|
||||||
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
|
|
||||||
'channelIdx': message.channelIdx,
|
|
||||||
'pathLen': message.pathLen,
|
|
||||||
'textType': message.textType.value,
|
|
||||||
'senderTimestamp': message.senderTimestamp,
|
|
||||||
'text': message.text,
|
|
||||||
'isSarMarker': message.isSarMarker,
|
|
||||||
'sarGpsCoordinates': message.sarGpsCoordinates != null
|
|
||||||
? {
|
|
||||||
'latitude': message.sarGpsCoordinates!.latitude,
|
|
||||||
'longitude': message.sarGpsCoordinates!.longitude,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
'sarNotes': message.sarNotes,
|
|
||||||
'sarCustomEmoji': message.sarCustomEmoji,
|
|
||||||
'sarColorIndex': message.sarColorIndex,
|
|
||||||
'receivedAt': message.receivedAt.toIso8601String(),
|
|
||||||
'senderName': message.senderName,
|
|
||||||
'deliveryStatus': message.deliveryStatus.name,
|
|
||||||
'expectedAckTag': message.expectedAckTag,
|
|
||||||
'suggestedTimeoutMs': message.suggestedTimeoutMs,
|
|
||||||
'roundTripTimeMs': message.roundTripTimeMs,
|
|
||||||
'deliveredAt': message.deliveredAt?.toIso8601String(),
|
|
||||||
'recipientPublicKey': message.recipientPublicKey?.toList(),
|
|
||||||
'retryAttempt': message.retryAttempt,
|
|
||||||
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
|
|
||||||
'usedFloodFallback': message.usedFloodFallback,
|
|
||||||
'isRead': message.isRead,
|
|
||||||
'echoCount': message.echoCount,
|
|
||||||
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
|
|
||||||
'lastEchoSnrRaw': message.lastEchoSnrRaw,
|
|
||||||
'lastEchoRssiDbm': message.lastEchoRssiDbm,
|
|
||||||
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
|
|
||||||
'isDrawing': message.isDrawing,
|
|
||||||
'drawingId': message.drawingId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert Contact to JSON
|
|
||||||
Map<String, dynamic> _contactToJson(Contact contact) {
|
|
||||||
return {
|
|
||||||
'publicKey': contact.publicKey.toList(),
|
|
||||||
'publicKeyHex': contact.publicKeyHex,
|
|
||||||
'type': contact.type.value,
|
|
||||||
'flags': contact.flags,
|
|
||||||
'outPathLen': contact.outPathLen,
|
|
||||||
'outPath': contact.outPath.toList(),
|
|
||||||
'advName': contact.advName,
|
|
||||||
'lastAdvert': contact.lastAdvert,
|
|
||||||
'advLat': contact.advLat,
|
|
||||||
'advLon': contact.advLon,
|
|
||||||
'lastMod': contact.lastMod,
|
|
||||||
'telemetry': contact.telemetry != null
|
|
||||||
? {
|
|
||||||
'batteryPercentage': contact.telemetry!.batteryPercentage,
|
|
||||||
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
|
|
||||||
'temperature': contact.telemetry!.temperature,
|
|
||||||
'humidity': contact.telemetry!.humidity,
|
|
||||||
'pressure': contact.telemetry!.pressure,
|
|
||||||
'gpsLocation': contact.telemetry!.gpsLocation != null
|
|
||||||
? {
|
|
||||||
'latitude': contact.telemetry!.gpsLocation!.latitude,
|
|
||||||
'longitude': contact.telemetry!.gpsLocation!.longitude,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear message history
|
|
||||||
void clearMessageHistory() {
|
|
||||||
_messageHistory.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear contact list
|
|
||||||
void clearContacts() {
|
|
||||||
_contacts.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|
||||||
|
|
||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +43,11 @@ class VoiceCodecService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
|
||||||
/// Returns the raw Codec2-encoded bytes.
|
|
||||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
|
||||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||||
@@ -59,24 +60,14 @@ class VoiceCodecService {
|
|||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) async {
|
) async {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
final c2Mode = codec2ModeFor(mode);
|
|
||||||
final c2 = Codec2.create(c2Mode);
|
|
||||||
final spf = c2.samplesPerFrame;
|
|
||||||
c2.destroy();
|
|
||||||
|
|
||||||
// Estimate total samples (use actual data or silence per missing packet)
|
|
||||||
final all = <Int16List>[];
|
final all = <Int16List>[];
|
||||||
for (final pkt in packets) {
|
for (final pkt in packets) {
|
||||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||||
// Silence for missing packet — duration approximated by mode
|
all.add(Int16List(mode.samplesPerPacket));
|
||||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
|
||||||
all.add(Int16List(silenceSamples));
|
|
||||||
} else {
|
} else {
|
||||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
all.add(await decode(pkt.codec2Data, mode));
|
||||||
all.add(decoded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||||
final result = Int16List(total);
|
final result = Int16List(total);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
|
||||||
enum Codec2Mode {
|
enum Codec2Mode {
|
||||||
mode3200(0),
|
mode3200(0),
|
||||||
mode2400(1),
|
mode2400(1),
|
||||||
@@ -19,13 +18,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
int get bytesPerSecond {
|
int get bytesPerSecond {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case mode3200: return 400;
|
case mode3200:
|
||||||
case mode700c: return 100;
|
return 400;
|
||||||
case mode1200: return 150;
|
case mode700c:
|
||||||
case mode1300: return 175;
|
return 100;
|
||||||
case mode1400: return 175;
|
case mode1200:
|
||||||
case mode1600: return 200;
|
return 150;
|
||||||
case mode2400: return 300;
|
case mode1300:
|
||||||
|
return 175;
|
||||||
|
case mode1400:
|
||||||
|
return 175;
|
||||||
|
case mode1600:
|
||||||
|
return 200;
|
||||||
|
case mode2400:
|
||||||
|
return 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +46,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,5 @@ class VoiceCodecService {
|
|||||||
Future<Int16List> decodePackets(
|
Future<Int16List> decodePackets(
|
||||||
List<VoicePacket?> packets,
|
List<VoicePacket?> packets,
|
||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) =>
|
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
Future.error(UnsupportedError('Voice not supported on web'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
/// Plays decoded mono PCM samples by writing a WAV file
|
||||||
/// to the system temp directory and using [AudioPlayer].
|
/// to the system temp directory and using [AudioPlayer].
|
||||||
class VoicePlayerService {
|
class VoicePlayerService {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -49,16 +49,17 @@ class VoicePlayerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||||
Future<void> play(Int16List pcmSamples) async {
|
|
||||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||||
if (_isPlaying) await stop();
|
if (_isPlaying) await stop();
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
_duration = Duration(
|
||||||
|
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||||
|
);
|
||||||
_playbackStartedAt = DateTime.now();
|
_playbackStartedAt = DateTime.now();
|
||||||
_events.add(null);
|
_events.add(null);
|
||||||
|
|
||||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||||
final tmpDir = await getTemporaryDirectory();
|
final tmpDir = await getTemporaryDirectory();
|
||||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||||
await file.writeAsBytes(wavBytes);
|
await file.writeAsBytes(wavBytes);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||||
///
|
///
|
||||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||||
@@ -27,12 +27,16 @@ class VoiceRecorderService {
|
|||||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||||
Stream<Int16List> startCapture({
|
Stream<Int16List> startCapture({
|
||||||
Duration chunkDuration = const Duration(seconds: 1),
|
Duration chunkDuration = const Duration(seconds: 1),
|
||||||
|
int sampleRateHz = 8000,
|
||||||
bool enableBandPassFilter = true,
|
bool enableBandPassFilter = true,
|
||||||
bool enableCompressor = true,
|
bool enableCompressor = true,
|
||||||
bool enableLimiter = true,
|
bool enableLimiter = true,
|
||||||
|
bool enableAutoGain = false,
|
||||||
|
bool enableEchoCancellation = false,
|
||||||
|
bool enableNoiseSuppression = false,
|
||||||
}) {
|
}) {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
@@ -43,39 +47,59 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
chunkDuration,
|
chunkDuration,
|
||||||
|
sampleRateHz: sampleRateHz,
|
||||||
enableBandPassFilter: enableBandPassFilter,
|
enableBandPassFilter: enableBandPassFilter,
|
||||||
enableCompressor: enableCompressor,
|
enableCompressor: enableCompressor,
|
||||||
enableLimiter: enableLimiter,
|
enableLimiter: enableLimiter,
|
||||||
|
enableAutoGain: enableAutoGain,
|
||||||
|
enableEchoCancellation: enableEchoCancellation,
|
||||||
|
enableNoiseSuppression: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
return _controller!.stream;
|
return _controller!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording(
|
Future<void> _startRecording(
|
||||||
Duration chunkDuration, {
|
Duration chunkDuration, {
|
||||||
|
required int sampleRateHz,
|
||||||
required bool enableBandPassFilter,
|
required bool enableBandPassFilter,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
|
required bool enableAutoGain,
|
||||||
|
required bool enableEchoCancellation,
|
||||||
|
required bool enableNoiseSuppression,
|
||||||
}) async {
|
}) async {
|
||||||
final config = const RecordConfig(
|
final useBandPassFilter = enableBandPassFilter;
|
||||||
|
final useCompressor = enableCompressor;
|
||||||
|
final useLimiter = enableLimiter;
|
||||||
|
final config = RecordConfig(
|
||||||
encoder: AudioEncoder.pcm16bits,
|
encoder: AudioEncoder.pcm16bits,
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
numChannels: 1,
|
numChannels: 1,
|
||||||
bitRate: 128000, // ignored for PCM, but required by API
|
bitRate: 128000, // ignored for PCM, but required by API
|
||||||
|
autoGain: enableAutoGain,
|
||||||
|
echoCancel: enableEchoCancellation,
|
||||||
|
noiseSuppress: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = await _recorder.startStream(config);
|
final stream = await _recorder.startStream(config);
|
||||||
final voiceFilter = _VoiceBandPassFilter(
|
final voiceFilter = _VoiceBandPassFilter(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
lowCutHz: 250.0,
|
lowCutHz: 250.0,
|
||||||
highCutHz: 3400.0,
|
highCutHz: 3400.0,
|
||||||
);
|
);
|
||||||
final dynamics = _VoiceDynamicsProcessor(
|
final dynamics = _VoiceDynamicsProcessor(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
enableCompressor: enableCompressor,
|
thresholdDb: -18.0,
|
||||||
enableLimiter: enableLimiter,
|
ratio: 2.5,
|
||||||
|
attackMs: 8.0,
|
||||||
|
releaseMs: 120.0,
|
||||||
|
makeupGainDb: 4.0,
|
||||||
|
enableCompressor: useCompressor,
|
||||||
|
enableLimiter: useLimiter,
|
||||||
);
|
);
|
||||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
final chunkBytes =
|
||||||
|
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
|
|
||||||
_sub = stream.listen(
|
_sub = stream.listen(
|
||||||
@@ -85,9 +109,7 @@ class VoiceRecorderService {
|
|||||||
final chunk = buffer.sublist(0, chunkBytes);
|
final chunk = buffer.sublist(0, chunkBytes);
|
||||||
buffer.removeRange(0, chunkBytes);
|
buffer.removeRange(0, chunkBytes);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -95,9 +117,7 @@ class VoiceRecorderService {
|
|||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
final padded = _padToEven(buffer);
|
final padded = _padToEven(buffer);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
_controller?.close();
|
_controller?.close();
|
||||||
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
|
|||||||
|
|
||||||
_VoiceDynamicsProcessor({
|
_VoiceDynamicsProcessor({
|
||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
|
required double thresholdDb,
|
||||||
|
required double ratio,
|
||||||
|
required double attackMs,
|
||||||
|
required double releaseMs,
|
||||||
|
required double makeupGainDb,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: thresholdDb,
|
||||||
ratio: 2.5,
|
ratio: ratio,
|
||||||
attackMs: 8.0,
|
attackMs: attackMs,
|
||||||
releaseMs: 120.0,
|
releaseMs: releaseMs,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: makeupGainDb,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +1,141 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
enum AppThemeMode {
|
enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
|
||||||
light,
|
|
||||||
dark,
|
|
||||||
sarRed,
|
|
||||||
sarGreen,
|
|
||||||
sarNavyBlue,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
|
static ThemeData _withSharedControls(ThemeData theme) {
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
return theme.copyWith(
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return colorScheme.onSurface.withValues(alpha: 0.38);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.onPrimary;
|
||||||
|
}
|
||||||
|
return colorScheme.outline;
|
||||||
|
}),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.disabled)) {
|
||||||
|
return colorScheme.onSurface.withValues(alpha: 0.12);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primary;
|
||||||
|
}
|
||||||
|
return colorScheme.surfaceContainerHighest;
|
||||||
|
}),
|
||||||
|
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return colorScheme.primary;
|
||||||
|
}
|
||||||
|
return colorScheme.outlineVariant;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Light theme (Blue)
|
// Light theme (Blue)
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
useMaterial3: true,
|
||||||
seedColor: Colors.blue,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
brightness: Brightness.light,
|
seedColor: Colors.blue,
|
||||||
),
|
brightness: Brightness.light,
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
elevation: 2,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
),
|
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
cardTheme: CardThemeData(
|
||||||
border: OutlineInputBorder(
|
elevation: 2,
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
),
|
),
|
||||||
filled: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme (Blue)
|
// Dark theme (Blue)
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
useMaterial3: true,
|
||||||
seedColor: Colors.blue,
|
colorScheme: ColorScheme.fromSeed(
|
||||||
brightness: Brightness.dark,
|
seedColor: Colors.blue,
|
||||||
),
|
brightness: Brightness.dark,
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
centerTitle: true,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
elevation: 2,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
),
|
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
cardTheme: CardThemeData(
|
||||||
border: OutlineInputBorder(
|
elevation: 2,
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
filled: true,
|
||||||
),
|
),
|
||||||
filled: true,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAR Red theme (Emergency/Alert tones)
|
// SAR Red theme (Emergency/Alert tones)
|
||||||
static ThemeData get sarRedTheme {
|
static ThemeData get sarRedTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFFFF5252), // Bright red
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFFFF5252), // Bright red
|
||||||
primaryContainer: Color(0xFF8B0000), // Dark red
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFFFCDD2),
|
primaryContainer: Color(0xFF8B0000), // Dark red
|
||||||
secondary: Color(0xFFFF8A80),
|
onPrimaryContainer: Color(0xFFFFCDD2),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFFFF8A80),
|
||||||
secondaryContainer: Color(0xFFB71C1C),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFFFCDD2),
|
secondaryContainer: Color(0xFFB71C1C),
|
||||||
tertiary: Color(0xFFFF6E40),
|
onSecondaryContainer: Color(0xFFFFCDD2),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFFFF6E40),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF1A0000), // Very dark red-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFFFEBEE),
|
surface: Color(0xFF1A0000), // Very dark red-tinted
|
||||||
surfaceContainerHighest: Color(0xFF2D0000),
|
onSurface: Color(0xFFFFEBEE),
|
||||||
onSurfaceVariant: Color(0xFFFFCDD2),
|
surfaceContainerHighest: Color(0xFF2D0000),
|
||||||
outline: Color(0xFFFF5252),
|
onSurfaceVariant: Color(0xFFFFCDD2),
|
||||||
),
|
outline: Color(0xFFFF5252),
|
||||||
scaffoldBackgroundColor: const Color(0xFF1A0000),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF1A0000),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF2D0000),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFFFEBEE),
|
backgroundColor: Color(0xFF2D0000),
|
||||||
),
|
foregroundColor: Color(0xFFFFEBEE),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF2D0000),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF2D0000),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFFFF5252),
|
side: const BorderSide(color: Color(0xFFFF5252), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFFFF5252)),
|
||||||
borderSide: const BorderSide(color: Color(0xFFFF5252)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF2D0000),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF2D0000),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFFFF5252),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFFFF5252),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -125,58 +143,57 @@ class AppTheme {
|
|||||||
|
|
||||||
// SAR Green theme (All Clear/Safe tones)
|
// SAR Green theme (All Clear/Safe tones)
|
||||||
static ThemeData get sarGreenTheme {
|
static ThemeData get sarGreenTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFF69F0AE), // Bright green
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFF69F0AE), // Bright green
|
||||||
primaryContainer: Color(0xFF00695C), // Dark teal-green
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFB9F6CA),
|
primaryContainer: Color(0xFF00695C), // Dark teal-green
|
||||||
secondary: Color(0xFF64FFDA),
|
onPrimaryContainer: Color(0xFFB9F6CA),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFF64FFDA),
|
||||||
secondaryContainer: Color(0xFF004D40),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFB9F6CA),
|
secondaryContainer: Color(0xFF004D40),
|
||||||
tertiary: Color(0xFF1DE9B6),
|
onSecondaryContainer: Color(0xFFB9F6CA),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFF1DE9B6),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF001A12), // Very dark green-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFE8F5E9),
|
surface: Color(0xFF001A12), // Very dark green-tinted
|
||||||
surfaceContainerHighest: Color(0xFF002D1F),
|
onSurface: Color(0xFFE8F5E9),
|
||||||
onSurfaceVariant: Color(0xFFB9F6CA),
|
surfaceContainerHighest: Color(0xFF002D1F),
|
||||||
outline: Color(0xFF69F0AE),
|
onSurfaceVariant: Color(0xFFB9F6CA),
|
||||||
),
|
outline: Color(0xFF69F0AE),
|
||||||
scaffoldBackgroundColor: const Color(0xFF001A12),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF001A12),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF002D1F),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFE8F5E9),
|
backgroundColor: Color(0xFF002D1F),
|
||||||
),
|
foregroundColor: Color(0xFFE8F5E9),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF002D1F),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF002D1F),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFF69F0AE),
|
side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
|
||||||
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF002D1F),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF002D1F),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFF69F0AE),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFF69F0AE),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -184,58 +201,57 @@ class AppTheme {
|
|||||||
|
|
||||||
// SAR Navy Blue theme (Professional/Operations tones)
|
// SAR Navy Blue theme (Professional/Operations tones)
|
||||||
static ThemeData get sarNavyBlueTheme {
|
static ThemeData get sarNavyBlueTheme {
|
||||||
return ThemeData(
|
return _withSharedControls(
|
||||||
useMaterial3: true,
|
ThemeData(
|
||||||
colorScheme: const ColorScheme.dark(
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
colorScheme: const ColorScheme.dark(
|
||||||
primary: Color(0xFF5C9FFF), // Bright navy blue
|
brightness: Brightness.dark,
|
||||||
onPrimary: Color(0xFF000000),
|
primary: Color(0xFF5C9FFF), // Bright navy blue
|
||||||
primaryContainer: Color(0xFF003366), // Dark navy
|
onPrimary: Color(0xFF000000),
|
||||||
onPrimaryContainer: Color(0xFFBBDEFF),
|
primaryContainer: Color(0xFF003366), // Dark navy
|
||||||
secondary: Color(0xFF80B3FF),
|
onPrimaryContainer: Color(0xFFBBDEFF),
|
||||||
onSecondary: Color(0xFF000000),
|
secondary: Color(0xFF80B3FF),
|
||||||
secondaryContainer: Color(0xFF002244),
|
onSecondary: Color(0xFF000000),
|
||||||
onSecondaryContainer: Color(0xFFBBDEFF),
|
secondaryContainer: Color(0xFF002244),
|
||||||
tertiary: Color(0xFF4DB8FF),
|
onSecondaryContainer: Color(0xFFBBDEFF),
|
||||||
onTertiary: Color(0xFF000000),
|
tertiary: Color(0xFF4DB8FF),
|
||||||
error: Color(0xFFCF6679),
|
onTertiary: Color(0xFF000000),
|
||||||
onError: Color(0xFF000000),
|
error: Color(0xFFCF6679),
|
||||||
surface: Color(0xFF00111C), // Very dark blue-tinted
|
onError: Color(0xFF000000),
|
||||||
onSurface: Color(0xFFE3F2FD),
|
surface: Color(0xFF00111C), // Very dark blue-tinted
|
||||||
surfaceContainerHighest: Color(0xFF001A2D),
|
onSurface: Color(0xFFE3F2FD),
|
||||||
onSurfaceVariant: Color(0xFFBBDEFF),
|
surfaceContainerHighest: Color(0xFF001A2D),
|
||||||
outline: Color(0xFF5C9FFF),
|
onSurfaceVariant: Color(0xFFBBDEFF),
|
||||||
),
|
outline: Color(0xFF5C9FFF),
|
||||||
scaffoldBackgroundColor: const Color(0xFF00111C),
|
),
|
||||||
appBarTheme: const AppBarTheme(
|
scaffoldBackgroundColor: const Color(0xFF00111C),
|
||||||
centerTitle: true,
|
appBarTheme: const AppBarTheme(
|
||||||
elevation: 0,
|
centerTitle: true,
|
||||||
backgroundColor: Color(0xFF001A2D),
|
elevation: 0,
|
||||||
foregroundColor: Color(0xFFE3F2FD),
|
backgroundColor: Color(0xFF001A2D),
|
||||||
),
|
foregroundColor: Color(0xFFE3F2FD),
|
||||||
cardTheme: CardThemeData(
|
),
|
||||||
elevation: 2,
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF001A2D),
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
color: const Color(0xFF001A2D),
|
||||||
borderRadius: BorderRadius.circular(12),
|
shape: RoundedRectangleBorder(
|
||||||
side: const BorderSide(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Color(0xFF5C9FFF),
|
side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
|
||||||
width: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
border: OutlineInputBorder(
|
||||||
border: OutlineInputBorder(
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
|
||||||
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF001A2D),
|
||||||
),
|
),
|
||||||
filled: true,
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
fillColor: const Color(0xFF001A2D),
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: const Color(0xFF5C9FFF),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
foregroundColor: const Color(0xFF000000),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: const Color(0xFF5C9FFF),
|
|
||||||
foregroundColor: const Color(0xFF000000),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AvatarLabelHelper {
|
class AvatarLabelHelper {
|
||||||
|
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||||
|
|
||||||
static String buildLabel(String name) {
|
static String buildLabel(String name) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
|||||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
final parts = trimmed
|
final parts = _alnumChunks
|
||||||
.split(RegExp(r'[\s_-]+'))
|
.allMatches(trimmed)
|
||||||
|
.map((match) => match.group(0)!)
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final first = _take(parts[0], 1);
|
final first = _take(parts[0], 1);
|
||||||
final second = _take(parts[1], 1);
|
final second = _take(parts[1], 1);
|
||||||
return '$first$second'.toUpperCase();
|
return '$first$second'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _take(trimmed, 2).toUpperCase();
|
return _take(parts.first, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _take(String value, int count) {
|
static String _take(String value, int count) {
|
||||||
|
|||||||
174
lib/utils/contact_grouping.dart
Normal file
174
lib/utils/contact_grouping.dart
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class InferredContactGroup {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
final List<String> matchPrefixes;
|
||||||
|
|
||||||
|
const InferredContactGroup({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.contacts,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
}) : matchPrefixes = matchPrefixes ?? const <String>[];
|
||||||
|
|
||||||
|
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactGrouping {
|
||||||
|
static final RegExp _prefixedNamePattern = RegExp(
|
||||||
|
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||||
|
);
|
||||||
|
|
||||||
|
static String? inferredGroupLabelForContact(Contact contact) {
|
||||||
|
return _extractPrefix(contact.displayName)?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
|
||||||
|
return groupLabel?.contains(normalizedQuery) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedInferredGroupLabel(List<Contact> contacts) {
|
||||||
|
String? sharedLabel;
|
||||||
|
for (final contact in contacts) {
|
||||||
|
final label = inferredGroupLabelForContact(contact);
|
||||||
|
if (label == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sharedLabel == null) {
|
||||||
|
sharedLabel = label;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sharedLabel != label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sharedLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? sharedParentGroupLabel(List<String> labels) {
|
||||||
|
if (labels.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var commonPrefix = labels.first;
|
||||||
|
for (final label in labels.skip(1)) {
|
||||||
|
final maxLength = commonPrefix.length < label.length
|
||||||
|
? commonPrefix.length
|
||||||
|
: label.length;
|
||||||
|
var matchLength = 0;
|
||||||
|
while (matchLength < maxLength &&
|
||||||
|
commonPrefix.codeUnitAt(matchLength) ==
|
||||||
|
label.codeUnitAt(matchLength)) {
|
||||||
|
matchLength++;
|
||||||
|
}
|
||||||
|
commonPrefix = commonPrefix.substring(0, matchLength);
|
||||||
|
if (commonPrefix.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
|
||||||
|
if (separatorIndex < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
|
||||||
|
return parentLabel.length >= 3 ? parentLabel : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||||
|
return List<Contact>.from(contacts)
|
||||||
|
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<InferredContactGroup> inferGroups(
|
||||||
|
List<Contact> contacts, {
|
||||||
|
int minGroupSize = 4,
|
||||||
|
int? maxNamedGroups,
|
||||||
|
String? overflowGroupLabel,
|
||||||
|
}) {
|
||||||
|
final sortedContacts = sortByLastSeen(contacts);
|
||||||
|
final groupedContacts = <String, List<Contact>>{};
|
||||||
|
final groupLabels = <String, String>{};
|
||||||
|
|
||||||
|
for (final contact in sortedContacts) {
|
||||||
|
final prefix = _extractPrefix(contact.displayName);
|
||||||
|
if (prefix == null) continue;
|
||||||
|
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
|
||||||
|
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rankedGroups = <InferredContactGroup>[];
|
||||||
|
for (final entry in groupedContacts.entries) {
|
||||||
|
if (entry.value.length < minGroupSize) continue;
|
||||||
|
rankedGroups.add(
|
||||||
|
InferredContactGroup(
|
||||||
|
key: entry.key,
|
||||||
|
label: groupLabels[entry.key] ?? entry.key,
|
||||||
|
contacts: entry.value,
|
||||||
|
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
|
||||||
|
|
||||||
|
if (maxNamedGroups != null &&
|
||||||
|
overflowGroupLabel != null &&
|
||||||
|
rankedGroups.length > maxNamedGroups) {
|
||||||
|
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
|
||||||
|
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
|
||||||
|
final parentLabel = sharedParentGroupLabel([
|
||||||
|
for (final group in overflowGroups) ...group.matchPrefixes,
|
||||||
|
]);
|
||||||
|
if (parentLabel != null) {
|
||||||
|
final overflowContacts = overflowGroups
|
||||||
|
.expand((group) => group.contacts)
|
||||||
|
.toList();
|
||||||
|
return [
|
||||||
|
...retainedGroups,
|
||||||
|
InferredContactGroup(
|
||||||
|
key: parentLabel,
|
||||||
|
label: parentLabel,
|
||||||
|
contacts: sortByLastSeen(overflowContacts),
|
||||||
|
matchPrefixes: [parentLabel],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rankedGroups;
|
||||||
|
}
|
||||||
|
return rankedGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _GroupPrefix? _extractPrefix(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final match = _prefixedNamePattern.firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final rawPrefix = match.group(1);
|
||||||
|
final separator = match.group(2);
|
||||||
|
if (rawPrefix == null || separator == null) return null;
|
||||||
|
|
||||||
|
return _GroupPrefix(
|
||||||
|
key: rawPrefix.toUpperCase(),
|
||||||
|
label: '$rawPrefix$separator',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupPrefix {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _GroupPrefix({required this.key, required this.label});
|
||||||
|
}
|
||||||
@@ -4,38 +4,55 @@ import '../models/contact.dart';
|
|||||||
|
|
||||||
class DecodedLogRxRoute {
|
class DecodedLogRxRoute {
|
||||||
final int payloadType;
|
final int payloadType;
|
||||||
final List<int> pathHashes;
|
final int pathDescriptor;
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hashSize;
|
||||||
|
|
||||||
const DecodedLogRxRoute({
|
const DecodedLogRxRoute({
|
||||||
required this.payloadType,
|
required this.payloadType,
|
||||||
required this.pathHashes,
|
required this.pathDescriptor,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hashSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
|
List<String> get hopHashes =>
|
||||||
|
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
|
||||||
|
|
||||||
|
int get hopCount => hopHashes.length;
|
||||||
|
|
||||||
|
String? get originalSenderHashHex =>
|
||||||
|
hopHashes.isEmpty ? null : hopHashes.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResolvedNodeHash {
|
class ResolvedNodeHash {
|
||||||
final int hash;
|
final String hashHex;
|
||||||
final String label;
|
final String label;
|
||||||
final bool isOwnNode;
|
final bool isOwnNode;
|
||||||
final bool isUniqueMatch;
|
final bool isUniqueMatch;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
const ResolvedNodeHash({
|
const ResolvedNodeHash({
|
||||||
required this.hash,
|
required this.hashHex,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isOwnNode,
|
required this.isOwnNode,
|
||||||
required this.isUniqueMatch,
|
required this.isUniqueMatch,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
|
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class LogRxRouteDecoder {
|
class LogRxRouteDecoder {
|
||||||
const LogRxRouteDecoder._();
|
const LogRxRouteDecoder._();
|
||||||
|
|
||||||
static DecodedLogRxRoute? decode(Uint8List rawData) {
|
static DecodedLogRxRoute? decode(
|
||||||
|
Uint8List rawData, {
|
||||||
|
int? preferredHashSize,
|
||||||
|
}) {
|
||||||
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
||||||
|
|
||||||
final rawPacketData = rawData.sublist(3);
|
final rawPacketData = rawData.sublist(3);
|
||||||
@@ -52,44 +69,147 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rawPacketData.length <= index) return null;
|
if (rawPacketData.length <= index) return null;
|
||||||
final pathLen = rawPacketData[index++];
|
final pathDescriptor = rawPacketData[index++];
|
||||||
if (rawPacketData.length < index + pathLen) return null;
|
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||||
|
final pathByteLen = pathMode == 0
|
||||||
|
? pathDescriptor
|
||||||
|
: descriptorByteLength(pathDescriptor);
|
||||||
|
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
|
||||||
|
final hashSize = pathMode == 0
|
||||||
|
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
|
||||||
|
: (descriptorHashSize(pathDescriptor) ??
|
||||||
|
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
|
||||||
|
|
||||||
return DecodedLogRxRoute(
|
return DecodedLogRxRoute(
|
||||||
payloadType: payloadType,
|
payloadType: payloadType,
|
||||||
pathHashes: rawPacketData.sublist(index, index + pathLen),
|
pathDescriptor: pathDescriptor,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? descriptorHashSize(int pathDescriptor) {
|
||||||
|
final normalized = pathDescriptor & 0xFF;
|
||||||
|
final mode = normalized >> 6;
|
||||||
|
if (mode == 3) return null;
|
||||||
|
return mode + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? descriptorHopCount(int pathDescriptor) {
|
||||||
|
final hashSize = descriptorHashSize(pathDescriptor);
|
||||||
|
if (hashSize == null) return null;
|
||||||
|
return (pathDescriptor & 0xFF) & 0x3F;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? descriptorByteLength(int pathDescriptor) {
|
||||||
|
final hashSize = descriptorHashSize(pathDescriptor);
|
||||||
|
final hopCount = descriptorHopCount(pathDescriptor);
|
||||||
|
if (hashSize == null || hopCount == null) return null;
|
||||||
|
final byteLen = hopCount * hashSize;
|
||||||
|
return byteLen <= 64 ? byteLen : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
|
||||||
|
if (pathBytes.isEmpty) return 1;
|
||||||
|
|
||||||
|
final normalizedPreferred =
|
||||||
|
preferredHashSize != null &&
|
||||||
|
preferredHashSize >= 1 &&
|
||||||
|
preferredHashSize <= 3
|
||||||
|
? preferredHashSize
|
||||||
|
: null;
|
||||||
|
if (normalizedPreferred != null &&
|
||||||
|
pathBytes.length % normalizedPreferred == 0) {
|
||||||
|
return normalizedPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final candidate in const [3, 2, 1]) {
|
||||||
|
if (pathBytes.length % candidate == 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> splitHopHashes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||||
|
return pathBytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||||
|
hops.add(
|
||||||
|
pathBytes
|
||||||
|
.sublist(index, index + hashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> reverseHopBytes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||||
|
return List<int>.from(pathBytes.reversed);
|
||||||
|
}
|
||||||
|
|
||||||
|
final reversed = <int>[];
|
||||||
|
for (
|
||||||
|
var index = pathBytes.length - hashSize;
|
||||||
|
index >= 0;
|
||||||
|
index -= hashSize
|
||||||
|
) {
|
||||||
|
reversed.addAll(pathBytes.sublist(index, index + hashSize));
|
||||||
|
}
|
||||||
|
return reversed;
|
||||||
|
}
|
||||||
|
|
||||||
static ResolvedNodeHash resolveHash(
|
static ResolvedNodeHash resolveHash(
|
||||||
int hash, {
|
String hashHex, {
|
||||||
required Iterable<Contact> contacts,
|
required Iterable<Contact> contacts,
|
||||||
Uint8List? ownPublicKey,
|
Uint8List? ownPublicKey,
|
||||||
String? ownName,
|
String? ownName,
|
||||||
|
double? ownLatitude,
|
||||||
|
double? ownLongitude,
|
||||||
}) {
|
}) {
|
||||||
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
? ownPublicKey.first
|
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||||
: null;
|
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
|
||||||
if (ownHash == hash) {
|
|
||||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||||
? '$ownName (you)'
|
? '$ownName (you)'
|
||||||
: 'You';
|
: 'You';
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: ownLabel,
|
label: ownLabel,
|
||||||
isOwnNode: true,
|
isOwnNode: true,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: ownLatitude,
|
||||||
|
longitude: ownLongitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final matches = contacts.where((contact) {
|
final matches = contacts.where((contact) {
|
||||||
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
|
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
if (matches.isEmpty) {
|
if (matches.isEmpty) {
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: 'Unknown',
|
label: 'Unknown',
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
@@ -98,12 +218,15 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length == 1) {
|
if (matches.length == 1) {
|
||||||
|
final location = matches.first.displayLocation;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: matches.first.displayName,
|
label: matches.first.displayName,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +242,19 @@ class LogRxRouteDecoder {
|
|||||||
? '$candidateNames +$extraCount'
|
? '$candidateNames +$extraCount'
|
||||||
: candidateNames;
|
: candidateNames;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: label,
|
label: label,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
matchCount: matches.length,
|
matchCount: matches.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _bytesToHex(Uint8List? bytes) {
|
||||||
|
if (bytes == null || bytes.isEmpty) return null;
|
||||||
|
return bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
|
||||||
/// Extension for Message to provide localized delivery status
|
/// Extension for Message to provide localized delivery status
|
||||||
extension MessageLocalization on Message {
|
extension MessageLocalization on Message {
|
||||||
/// Get localized delivery status text
|
/// Get localized delivery status text
|
||||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
|
||||||
|
|
||||||
// For channel messages, show echo count instead of delivery status
|
// For channel messages, show echo count instead of delivery status
|
||||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||||
final latestMeta = _formatEchoMeta(context);
|
final latestMeta = _formatEchoMeta(context);
|
||||||
if (echoCount == 0) {
|
if (echoCount == 0) {
|
||||||
|
if (messagesProvider.hasChannelSendWarning(id)) {
|
||||||
|
return 'Broadcast may have failed';
|
||||||
|
}
|
||||||
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
return l10n.broadcast; // "Broadcast (no echoes yet)"
|
||||||
} else if (echoCount == 1) {
|
} else if (echoCount == 1) {
|
||||||
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
return latestMeta == null ? '1 node' : '1 node • $latestMeta';
|
||||||
@@ -26,29 +33,51 @@ extension MessageLocalization on Message {
|
|||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
if (isContactMessage) {
|
if (isContactMessage) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.pending;
|
return routeMetadata == null
|
||||||
|
? l10n.pending
|
||||||
|
: '${l10n.pending} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return routeMetadata == null
|
||||||
|
? l10n.sent
|
||||||
|
: '${l10n.sent} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (roundTripTimeMs != null) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return routeMetadata == null
|
||||||
|
? l10n.deliveredWithTime(roundTripTimeMs!)
|
||||||
|
: '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return routeMetadata == null
|
||||||
|
? l10n.delivered
|
||||||
|
: '${l10n.delivered} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.failed;
|
return routeMetadata == null
|
||||||
|
? l10n.failed
|
||||||
|
: '${l10n.failed} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
239
lib/utils/trace_node_resolver.dart
Normal file
239
lib/utils/trace_node_resolver.dart
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class ResolvedTraceNode {
|
||||||
|
final List<MeshMapNode> candidates;
|
||||||
|
final int matchCount;
|
||||||
|
final bool usedOnlineFallback;
|
||||||
|
final int selectedIndex;
|
||||||
|
|
||||||
|
const ResolvedTraceNode({
|
||||||
|
required this.candidates,
|
||||||
|
required this.matchCount,
|
||||||
|
required this.usedOnlineFallback,
|
||||||
|
this.selectedIndex = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
MeshMapNode? get node =>
|
||||||
|
candidates.isEmpty ? null : candidates[selectedIndex];
|
||||||
|
bool get hasMatch => node != null;
|
||||||
|
bool get isAmbiguous => matchCount > 1;
|
||||||
|
bool get canCycle => candidates.length > 1;
|
||||||
|
|
||||||
|
String? get matchSummary {
|
||||||
|
if (matchCount <= 1) return null;
|
||||||
|
final source = usedOnlineFallback ? 'online' : 'local';
|
||||||
|
return '$matchCount $source matches';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get cycleSummary =>
|
||||||
|
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
|
||||||
|
|
||||||
|
ResolvedTraceNode cycle() {
|
||||||
|
if (!canCycle) return this;
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: candidates,
|
||||||
|
matchCount: matchCount,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
selectedIndex: (selectedIndex + 1) % candidates.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TraceNodeResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const TraceNodeResolver._();
|
||||||
|
|
||||||
|
static ResolvedTraceNode resolveBest({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
|
required String? prefixHex,
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
String? preferredPrefix,
|
||||||
|
}) {
|
||||||
|
if (prefixHex == null || prefixHex.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
candidates: <MeshMapNode>[],
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allMatches = nodes
|
||||||
|
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||||
|
.toList();
|
||||||
|
if (allMatches.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
candidates: <MeshMapNode>[],
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localMatches = allMatches
|
||||||
|
.where((node) => localPublicKeys.contains(node.publicKey))
|
||||||
|
.toList();
|
||||||
|
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
|
||||||
|
final usedOnlineFallback = localMatches.isEmpty;
|
||||||
|
|
||||||
|
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
|
||||||
|
final preferredMatches = pool
|
||||||
|
.where((node) => node.publicKey.startsWith(preferredPrefix))
|
||||||
|
.toList();
|
||||||
|
if (preferredMatches.isNotEmpty) {
|
||||||
|
pool = preferredMatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.sort((a, b) {
|
||||||
|
final distanceCompare =
|
||||||
|
_scoreNode(
|
||||||
|
a,
|
||||||
|
referenceA: referenceA,
|
||||||
|
referenceB: referenceB,
|
||||||
|
).compareTo(
|
||||||
|
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
|
||||||
|
);
|
||||||
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
|
return b.updatedAtMs.compareTo(a.updatedAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: List<MeshMapNode>.unmodifiable(pool),
|
||||||
|
matchCount: pool.length,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ResolvedTraceNode> alignPathSelections({
|
||||||
|
required List<ResolvedTraceNode> nodes,
|
||||||
|
MeshMapNode? startNode,
|
||||||
|
MeshMapNode? endNode,
|
||||||
|
}) {
|
||||||
|
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final candidateCosts = List.generate(
|
||||||
|
nodes.length,
|
||||||
|
(_) => <double>[],
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
final previousChoice = List.generate(
|
||||||
|
nodes.length,
|
||||||
|
(_) => <int>[],
|
||||||
|
growable: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
final currentCandidates = nodes[i].candidates;
|
||||||
|
candidateCosts[i] = List<double>.filled(
|
||||||
|
currentCandidates.length,
|
||||||
|
double.infinity,
|
||||||
|
);
|
||||||
|
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
|
||||||
|
|
||||||
|
for (var j = 0; j < currentCandidates.length; j++) {
|
||||||
|
final current = currentCandidates[j];
|
||||||
|
if (i == 0) {
|
||||||
|
candidateCosts[i][j] = startNode == null
|
||||||
|
? 0
|
||||||
|
: _distanceBetweenNodes(startNode, current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final previousCandidates = nodes[i - 1].candidates;
|
||||||
|
for (var k = 0; k < previousCandidates.length; k++) {
|
||||||
|
final candidateCost =
|
||||||
|
candidateCosts[i - 1][k] +
|
||||||
|
_distanceBetweenNodes(previousCandidates[k], current);
|
||||||
|
if (candidateCost < candidateCosts[i][j]) {
|
||||||
|
candidateCosts[i][j] = candidateCost;
|
||||||
|
previousChoice[i][j] = k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestLastIndex = 0;
|
||||||
|
var bestLastCost = double.infinity;
|
||||||
|
final lastCandidates = nodes.last.candidates;
|
||||||
|
for (var i = 0; i < lastCandidates.length; i++) {
|
||||||
|
final endCost = endNode == null
|
||||||
|
? 0
|
||||||
|
: _distanceBetweenNodes(lastCandidates[i], endNode);
|
||||||
|
final totalCost = candidateCosts.last[i] + endCost;
|
||||||
|
if (totalCost < bestLastCost) {
|
||||||
|
bestLastCost = totalCost;
|
||||||
|
bestLastIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedIndices = List<int>.filled(nodes.length, 0);
|
||||||
|
selectedIndices[nodes.length - 1] = bestLastIndex;
|
||||||
|
for (var i = nodes.length - 1; i > 0; i--) {
|
||||||
|
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
|
||||||
|
final resolved = nodes[index];
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
candidates: resolved.candidates,
|
||||||
|
matchCount: resolved.matchCount,
|
||||||
|
usedOnlineFallback: resolved.usedOnlineFallback,
|
||||||
|
selectedIndex: selectedIndices[index],
|
||||||
|
);
|
||||||
|
}, growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreNode(
|
||||||
|
MeshMapNode node, {
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(node.latitude, node.longitude);
|
||||||
|
if (referenceA != null && referenceB != null) {
|
||||||
|
return _distanceToSegmentMeters(point, referenceA, referenceB);
|
||||||
|
}
|
||||||
|
if (referenceA != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceA);
|
||||||
|
}
|
||||||
|
if (referenceB != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceB);
|
||||||
|
}
|
||||||
|
return double.maxFinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
|
||||||
|
return _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
LatLng(a.latitude, a.longitude),
|
||||||
|
LatLng(b.latitude, b.longitude),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,20 +11,31 @@ const int _defaultLoRaCrcEnabled = 1;
|
|||||||
const int _defaultLoRaExplicitHeader = 1;
|
const int _defaultLoRaExplicitHeader = 1;
|
||||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||||
|
|
||||||
/// Identifies which Codec2 mode was used for a voice packet.
|
/// Identifies which voice codec/mode was used for a packet.
|
||||||
/// Matches the modeId byte in the text/binary packet header.
|
/// Matches the modeId byte in the text/binary packet header.
|
||||||
enum VoicePacketMode {
|
enum VoicePacketMode {
|
||||||
mode700c(0, '700C'),
|
mode700c(0, '700C', 8000, 100, 1600),
|
||||||
mode1200(1, '1200'),
|
mode1200(1, '1200', 8000, 150, 1040),
|
||||||
mode2400(2, '2400'),
|
mode2400(2, '2400', 8000, 300, 520),
|
||||||
mode1300(3, '1300'),
|
mode1300(3, '1300', 8000, 175, 880),
|
||||||
mode1400(4, '1400'),
|
mode1400(4, '1400', 8000, 175, 880),
|
||||||
mode1600(5, '1600'),
|
mode1600(5, '1600', 8000, 200, 800),
|
||||||
mode3200(6, '3200');
|
mode3200(6, '3200', 8000, 400, 400);
|
||||||
|
|
||||||
const VoicePacketMode(this.id, this.label);
|
const VoicePacketMode(
|
||||||
|
this.id,
|
||||||
|
this.label,
|
||||||
|
this.sampleRateHz,
|
||||||
|
this.bytesPerSecond,
|
||||||
|
this.packetDurationMs,
|
||||||
|
);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
|
final int sampleRateHz;
|
||||||
|
final int bytesPerSecond;
|
||||||
|
final int packetDurationMs;
|
||||||
|
|
||||||
|
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
|
||||||
|
|
||||||
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||||
(m) => m.id == id,
|
(m) => m.id == id,
|
||||||
@@ -153,23 +164,21 @@ class VoicePacket {
|
|||||||
|
|
||||||
/// Estimated audio duration of this packet in milliseconds.
|
/// Estimated audio duration of this packet in milliseconds.
|
||||||
int get durationMs {
|
int get durationMs {
|
||||||
// bytesPerSecond for each mode
|
try {
|
||||||
final bps = switch (mode) {
|
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||||
VoicePacketMode.mode700c => 100,
|
if (bytesPerSecond <= 0) return 0;
|
||||||
VoicePacketMode.mode1200 => 150,
|
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
|
||||||
VoicePacketMode.mode1300 => 175,
|
} catch (_) {
|
||||||
VoicePacketMode.mode1400 => 175,
|
// Be permissive with stale or malformed persisted voice metadata.
|
||||||
VoicePacketMode.mode1600 => 200,
|
return 0;
|
||||||
VoicePacketMode.mode2400 => 300,
|
}
|
||||||
VoicePacketMode.mode3200 => 400,
|
|
||||||
};
|
|
||||||
if (bps == 0) return 0;
|
|
||||||
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
final suffix = total > 0
|
||||||
|
? ' ${mode.label} [$index/${total - 1}]'
|
||||||
|
: ' [$index]';
|
||||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,10 +253,10 @@ class VoiceEnvelope {
|
|||||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||||
VoicePacketMode.mode700c => 100,
|
VoicePacketMode.mode700c => 100,
|
||||||
VoicePacketMode.mode1200 => 150,
|
VoicePacketMode.mode1200 => 150,
|
||||||
|
VoicePacketMode.mode2400 => 300,
|
||||||
VoicePacketMode.mode1300 => 175,
|
VoicePacketMode.mode1300 => 175,
|
||||||
VoicePacketMode.mode1400 => 175,
|
VoicePacketMode.mode1400 => 175,
|
||||||
VoicePacketMode.mode1600 => 200,
|
VoicePacketMode.mode1600 => 200,
|
||||||
VoicePacketMode.mode2400 => 300,
|
|
||||||
VoicePacketMode.mode3200 => 400,
|
VoicePacketMode.mode3200 => 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,8 +428,7 @@ class VoiceFetchRequest {
|
|||||||
this.version = 3,
|
this.version = 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) =>
|
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||||
text.startsWith(_prefix);
|
|
||||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
@@ -473,9 +481,7 @@ class VoiceFetchRequest {
|
|||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m'))
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
? 'missing'
|
|
||||||
: wantToken);
|
|
||||||
|
|
||||||
if (sid == null) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -602,7 +608,11 @@ String _toBase36(int value) => value.toRadixString(36);
|
|||||||
|
|
||||||
String _encodeSessionId(String sessionIdHex) {
|
String _encodeSessionId(String sessionIdHex) {
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
throw ArgumentError.value(
|
||||||
|
sessionIdHex,
|
||||||
|
'sessionIdHex',
|
||||||
|
'Expected 8 hex chars',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final value = int.parse(sessionIdHex, radix: 16);
|
final value = int.parse(sessionIdHex, radix: 16);
|
||||||
return value.toRadixString(36);
|
return value.toRadixString(36);
|
||||||
@@ -616,10 +626,7 @@ String? _decodeSessionId(String token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||||
final sorted = indices
|
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||||
.where((v) => v >= 0 && v <= 254)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
..sort();
|
||||||
if (sorted.isEmpty) return '';
|
if (sorted.isEmpty) return '';
|
||||||
final chunks = <String>[];
|
final chunks = <String>[];
|
||||||
@@ -632,7 +639,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
chunks.add(
|
chunks.add(
|
||||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
start == prev
|
||||||
|
? _toBase36(start)
|
||||||
|
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
);
|
);
|
||||||
start = curr;
|
start = curr;
|
||||||
prev = curr;
|
prev = curr;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user