mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
171 Commits
v2026.0308
...
v2026.0319
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6145462555 | ||
|
|
721fb7e7a2 | ||
|
|
5a58e45e13 | ||
|
|
de4258c0a4 | ||
|
|
d8cb35c595 | ||
|
|
8da69705ee | ||
|
|
e474168ba8 | ||
|
|
03dba8b3ce | ||
|
|
bc9e37bd22 | ||
|
|
a32d5d2c85 | ||
|
|
e683ab933f | ||
|
|
f286b0afd1 | ||
|
|
68ec72534d | ||
|
|
6af8b62d9b | ||
|
|
01fbb7de6c | ||
|
|
9303d24b69 | ||
|
|
140088fe2f | ||
|
|
67d8e1c906 | ||
|
|
29b75a3155 | ||
|
|
87cb890877 | ||
|
|
095458ece4 | ||
|
|
5659242672 | ||
|
|
3da4ba8fb2 | ||
|
|
89dc1f2862 | ||
|
|
251932bb47 | ||
|
|
b48139964a | ||
|
|
d29b85feb8 | ||
|
|
c01ef7a059 | ||
|
|
fa2b586ab7 | ||
|
|
bbd5d108cb | ||
|
|
e17f24a6bc | ||
|
|
372224a62c | ||
|
|
e4c8709ee2 | ||
|
|
3c469b62d0 | ||
|
|
1e97ba9831 | ||
|
|
6187067017 | ||
|
|
cd165d2372 | ||
|
|
59f6df4260 | ||
|
|
464fc5e715 | ||
|
|
a76ee4f8f6 | ||
|
|
c884f81ca5 | ||
|
|
993563f059 | ||
|
|
cfd233d38e | ||
|
|
9b14ff4e1a | ||
|
|
84cd8eec00 | ||
|
|
434d3df53e | ||
|
|
1fe260b95e | ||
|
|
d3d53995a6 | ||
|
|
efbddc8831 | ||
|
|
0e3acfa4ca | ||
|
|
450b8382e8 | ||
|
|
420690d683 | ||
|
|
fedd7b9a2b | ||
|
|
c82e0c3452 | ||
|
|
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 | ||
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
aa0777d4a3 |
20
.github/workflows/build-artifacts.yml
vendored
20
.github/workflows/build-artifacts.yml
vendored
@@ -40,6 +40,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v4
|
||||
@@ -95,6 +98,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable Linux desktop
|
||||
run: flutter config --enable-linux-desktop
|
||||
@@ -132,6 +138,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable Windows desktop
|
||||
shell: pwsh
|
||||
@@ -174,6 +183,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable macOS desktop
|
||||
run: flutter config --enable-macos-desktop
|
||||
@@ -216,6 +228,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
@@ -272,6 +287,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: stable
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Enable web
|
||||
run: flutter config --enable-web
|
||||
@@ -280,7 +298,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- 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
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
3
.github/workflows/build-multiplatform.yml
vendored
3
.github/workflows/build-multiplatform.yml
vendored
@@ -27,6 +27,9 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
pub-cache: true
|
||||
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,6 +28,7 @@ migrate_working_dir/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
pubspec_overrides.yaml
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
@@ -80,10 +81,11 @@ create_feature_graphic.py
|
||||
# Tool caches and local state
|
||||
.cachebro/
|
||||
.osgrep/
|
||||
third_party/lpcnet_flutter/
|
||||
|
||||
# Claude Code local settings (permissions, personal config)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Dart code 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ PODS:
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- record_ios (1.2.0):
|
||||
@@ -81,6 +84,8 @@ PODS:
|
||||
- Flutter
|
||||
- vibration (3.0.0):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||
@@ -97,6 +102,7 @@ DEPENDENCIES:
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -104,6 +110,7 @@ DEPENDENCIES:
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -141,6 +148,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
record_ios:
|
||||
@@ -155,6 +164,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
vibration:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||
@@ -173,6 +184,7 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
@@ -182,6 +194,7 @@ SPEC CHECKSUMS:
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +530,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +547,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +679,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +702,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 105;
|
||||
CURRENT_PROJECT_VERSION = 122;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,7 @@ import Flutter
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>105</string>
|
||||
<string>122</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -102,6 +102,7 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>bluetooth-peripheral</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -5,17 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001342">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000814">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.40706">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.353323">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="70.83361">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="115.148883">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="204.902574">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
1699
lib/l10n/app_de.arb
1699
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2920
lib/l10n/app_el.arb
2920
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
2149
lib/l10n/app_en.arb
2149
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1688
lib/l10n/app_es.arb
1688
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1693
lib/l10n/app_fr.arb
1693
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
1693
lib/l10n/app_it.arb
1693
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
2692
lib/l10n/app_localizations_pl.dart
Normal file
2692
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
2703
lib/l10n/app_localizations_pt.dart
Normal file
2703
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
2676
lib/l10n/app_localizations_tr.dart
Normal file
2676
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
2699
lib/l10n/app_localizations_uk.dart
Normal file
2699
lib/l10n/app_localizations_uk.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_pl.arb
Normal file
2579
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_pt.arb
Normal file
2579
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
1475
lib/l10n/app_ru.arb
1475
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
2579
lib/l10n/app_tr.arb
Normal file
2579
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_uk.arb
Normal file
2579
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2567
lib/l10n/app_zh.arb
2567
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
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_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'providers/connection_provider.dart';
|
||||
import 'providers/contacts_provider.dart';
|
||||
import 'providers/messages_provider.dart';
|
||||
@@ -19,14 +23,21 @@ import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/profile_manager.dart';
|
||||
import 'services/profile_workspace_coordinator.dart';
|
||||
import 'services/profiles_feature_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/welcome_wizard_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await WakelockPlus.enable();
|
||||
runApp(const MeshCoreSarApp());
|
||||
}
|
||||
|
||||
@@ -38,11 +49,13 @@ class MeshCoreSarApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
Locale? _locale;
|
||||
bool _isInitialized = false;
|
||||
bool _shouldShowPermissionDialog = false;
|
||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||
String? _pendingNotificationPayload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -51,6 +64,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
}
|
||||
|
||||
Future<void> _initializeApp() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final profilesEnabled = await ProfilesFeatureService.isEnabled();
|
||||
final activeProfileId =
|
||||
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
|
||||
await ProfileStorageScope.bootstrap(
|
||||
profilesEnabled: profilesEnabled,
|
||||
activeProfileId: activeProfileId,
|
||||
);
|
||||
await _loadThemePreference();
|
||||
await _loadLocalePreference();
|
||||
|
||||
@@ -62,6 +83,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Set up notification tap handler for update notifications
|
||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||
|
||||
// Check if we need to request location permissions
|
||||
await _checkLocationPermissions();
|
||||
@@ -70,10 +92,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
// Shows notification if update is available
|
||||
_checkForUpdates();
|
||||
|
||||
// Refresh remote mesh node cache in background when the app starts.
|
||||
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||
|
||||
setState(() {
|
||||
_wizardCompleted = wizardCompleted;
|
||||
_isInitialized = true;
|
||||
});
|
||||
|
||||
if (_pendingNotificationPayload != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final payload = _pendingNotificationPayload;
|
||||
_pendingNotificationPayload = null;
|
||||
_handleNotificationTap(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap
|
||||
@@ -82,10 +115,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
debugPrint('[Main] Notification tapped: $payload');
|
||||
|
||||
if (!_wizardCompleted) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update notification tap
|
||||
if (payload.startsWith('update:')) {
|
||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||
_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
|
||||
}
|
||||
@@ -158,7 +208,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
/// Shows notification if update is available
|
||||
Future<void> _checkForUpdates() async {
|
||||
// Only check for updates on Android
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||
return;
|
||||
}
|
||||
@@ -235,6 +285,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) {
|
||||
final manager = ProfileManager();
|
||||
manager.initialize();
|
||||
return manager;
|
||||
},
|
||||
),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
@@ -287,6 +344,37 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
),
|
||||
),
|
||||
ProxyProvider6<
|
||||
ProfileManager,
|
||||
AppProvider,
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
MapProvider,
|
||||
ProfileWorkspaceCoordinator
|
||||
>(
|
||||
update:
|
||||
(
|
||||
context,
|
||||
profileManager,
|
||||
appProvider,
|
||||
connectionProvider,
|
||||
contactsProvider,
|
||||
messagesProvider,
|
||||
mapProvider,
|
||||
previous,
|
||||
) => ProfileWorkspaceCoordinator(
|
||||
profileManager: profileManager,
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
messagesProvider: messagesProvider,
|
||||
sensorsProvider: context.read<SensorsProvider>(),
|
||||
mapProvider: mapProvider,
|
||||
drawingProvider: context.read<DrawingProvider>(),
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
appProvider: appProvider,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildMaterialApp(),
|
||||
);
|
||||
@@ -297,6 +385,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
builder: (context) {
|
||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final materialApp = MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
key: ValueKey<String?>(
|
||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||
),
|
||||
@@ -324,7 +413,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// 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)
|
||||
if (Platform.isAndroid) {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
|
||||
@@ -57,9 +57,7 @@ class Channel {
|
||||
} else {
|
||||
// Normal channel: require explicit secret
|
||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||
throw ArgumentError(
|
||||
'Normal channels require a 16-byte secret',
|
||||
);
|
||||
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||
}
|
||||
return Channel(
|
||||
index: index,
|
||||
@@ -78,6 +76,18 @@ class Channel {
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
static bool isHashChannelName(String channelName) {
|
||||
return channelName.trim().startsWith('#');
|
||||
}
|
||||
|
||||
static String pskBase64ForHashChannelName(String channelName) {
|
||||
final normalized = channelName.trim();
|
||||
if (!isHashChannelName(normalized)) {
|
||||
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||
}
|
||||
return base64.encode(_generateHashChannelSecret(normalized));
|
||||
}
|
||||
|
||||
/// Create the default public channel (channel 0)
|
||||
/// Uses the well-known pre-shared key from MeshCore
|
||||
factory Channel.publicChannel() {
|
||||
@@ -85,8 +95,22 @@ class Channel {
|
||||
index: 0,
|
||||
name: 'Public Channel',
|
||||
secret: Uint8List.fromList([
|
||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
||||
0x8b,
|
||||
0x33,
|
||||
0x87,
|
||||
0xe9,
|
||||
0xc5,
|
||||
0xcd,
|
||||
0xea,
|
||||
0x6a,
|
||||
0xc9,
|
||||
0xe5,
|
||||
0xed,
|
||||
0xba,
|
||||
0xa1,
|
||||
0x15,
|
||||
0xcd,
|
||||
0x72,
|
||||
]),
|
||||
flags: null,
|
||||
);
|
||||
@@ -95,6 +119,9 @@ class Channel {
|
||||
/// Check if this is a hash-based channel (name starts with '#')
|
||||
bool get isHashChannel => name.startsWith('#');
|
||||
|
||||
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||
String get pskBase64 => base64.encode(secret);
|
||||
|
||||
/// Display name for the channel
|
||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||
String get displayName {
|
||||
@@ -131,12 +158,7 @@ class Channel {
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
Channel copyWith({
|
||||
int? index,
|
||||
String? name,
|
||||
Uint8List? secret,
|
||||
int? flags,
|
||||
}) {
|
||||
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||
return Channel(
|
||||
index: index ?? this.index,
|
||||
name: name ?? this.name,
|
||||
|
||||
528
lib/models/config_profile.dart
Normal file
528
lib/models/config_profile.dart
Normal file
@@ -0,0 +1,528 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'channel.dart';
|
||||
|
||||
@immutable
|
||||
class DeviceConfigProfileSection {
|
||||
final int? frequencyKhz;
|
||||
final int? bandwidth;
|
||||
final int? spreadingFactor;
|
||||
final int? codingRate;
|
||||
final bool? repeatEnabled;
|
||||
final int? txPower;
|
||||
final int? telemetryModes;
|
||||
final int? advertLocationPolicy;
|
||||
final int? multiAcks;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final double? publicLatitude;
|
||||
final double? publicLongitude;
|
||||
|
||||
const DeviceConfigProfileSection({
|
||||
this.frequencyKhz,
|
||||
this.bandwidth,
|
||||
this.spreadingFactor,
|
||||
this.codingRate,
|
||||
this.repeatEnabled,
|
||||
this.txPower,
|
||||
this.telemetryModes,
|
||||
this.advertLocationPolicy,
|
||||
this.multiAcks,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.publicLatitude,
|
||||
this.publicLongitude,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
frequencyKhz == null &&
|
||||
bandwidth == null &&
|
||||
spreadingFactor == null &&
|
||||
codingRate == null &&
|
||||
repeatEnabled == null &&
|
||||
txPower == null &&
|
||||
telemetryModes == null &&
|
||||
advertLocationPolicy == null &&
|
||||
multiAcks == null &&
|
||||
manualAddContacts == null &&
|
||||
autoAddUsers == null &&
|
||||
autoAddRepeaters == null &&
|
||||
autoAddRoomServers == null &&
|
||||
autoAddSensors == null &&
|
||||
autoAddOverwriteOldest == null &&
|
||||
publicLatitude == null &&
|
||||
publicLongitude == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'frequencyKhz': frequencyKhz,
|
||||
'bandwidth': bandwidth,
|
||||
'spreadingFactor': spreadingFactor,
|
||||
'codingRate': codingRate,
|
||||
'repeatEnabled': repeatEnabled,
|
||||
'txPower': txPower,
|
||||
'telemetryModes': telemetryModes,
|
||||
'advertLocationPolicy': advertLocationPolicy,
|
||||
'multiAcks': multiAcks,
|
||||
'manualAddContacts': manualAddContacts,
|
||||
'autoAddUsers': autoAddUsers,
|
||||
'autoAddRepeaters': autoAddRepeaters,
|
||||
'autoAddRoomServers': autoAddRoomServers,
|
||||
'autoAddSensors': autoAddSensors,
|
||||
'autoAddOverwriteOldest': autoAddOverwriteOldest,
|
||||
'publicLatitude': publicLatitude,
|
||||
'publicLongitude': publicLongitude,
|
||||
};
|
||||
|
||||
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceConfigProfileSection(
|
||||
frequencyKhz: json['frequencyKhz'] as int?,
|
||||
bandwidth: json['bandwidth'] as int?,
|
||||
spreadingFactor: json['spreadingFactor'] as int?,
|
||||
codingRate: json['codingRate'] as int?,
|
||||
repeatEnabled: json['repeatEnabled'] as bool?,
|
||||
txPower: json['txPower'] as int?,
|
||||
telemetryModes: json['telemetryModes'] as int?,
|
||||
advertLocationPolicy: json['advertLocationPolicy'] as int?,
|
||||
multiAcks: json['multiAcks'] as int?,
|
||||
manualAddContacts: json['manualAddContacts'] as bool?,
|
||||
autoAddUsers: json['autoAddUsers'] as bool?,
|
||||
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
|
||||
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
|
||||
autoAddSensors: json['autoAddSensors'] as bool?,
|
||||
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
|
||||
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
|
||||
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class AppSettingsProfileSection {
|
||||
final bool? mapEnabled;
|
||||
final bool? contactsEnabled;
|
||||
final bool? sensorsEnabled;
|
||||
final bool? voiceSilenceTrimmingEnabled;
|
||||
final bool? voiceBandPassFilterEnabled;
|
||||
final bool? voiceCompressorEnabled;
|
||||
final bool? voiceLimiterEnabled;
|
||||
final bool? voiceAutoGainEnabled;
|
||||
final bool? voiceEchoCancellationEnabled;
|
||||
final bool? voiceNoiseSuppressionEnabled;
|
||||
final double? messageFontScale;
|
||||
final bool? autoRouteRotationEnabled;
|
||||
final bool? clearPathOnMaxRetry;
|
||||
final bool? nearestRelayFallbackEnabled;
|
||||
final int? voiceBitrate;
|
||||
final int? routeHashSize;
|
||||
final int? imageMaxSize;
|
||||
final int? imageCompression;
|
||||
final bool? imageGrayscale;
|
||||
final bool? imageUltraMode;
|
||||
final bool? showRxTxIndicators;
|
||||
final bool? fastLocationUpdatesEnabled;
|
||||
final double? fastLocationMovementThresholdMeters;
|
||||
final int? fastLocationActiveCadenceSeconds;
|
||||
|
||||
const AppSettingsProfileSection({
|
||||
this.mapEnabled,
|
||||
this.contactsEnabled,
|
||||
this.sensorsEnabled,
|
||||
this.voiceSilenceTrimmingEnabled,
|
||||
this.voiceBandPassFilterEnabled,
|
||||
this.voiceCompressorEnabled,
|
||||
this.voiceLimiterEnabled,
|
||||
this.voiceAutoGainEnabled,
|
||||
this.voiceEchoCancellationEnabled,
|
||||
this.voiceNoiseSuppressionEnabled,
|
||||
this.messageFontScale,
|
||||
this.autoRouteRotationEnabled,
|
||||
this.clearPathOnMaxRetry,
|
||||
this.nearestRelayFallbackEnabled,
|
||||
this.voiceBitrate,
|
||||
this.routeHashSize,
|
||||
this.imageMaxSize,
|
||||
this.imageCompression,
|
||||
this.imageGrayscale,
|
||||
this.imageUltraMode,
|
||||
this.showRxTxIndicators,
|
||||
this.fastLocationUpdatesEnabled,
|
||||
this.fastLocationMovementThresholdMeters,
|
||||
this.fastLocationActiveCadenceSeconds,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapEnabled == null &&
|
||||
contactsEnabled == null &&
|
||||
sensorsEnabled == null &&
|
||||
voiceSilenceTrimmingEnabled == null &&
|
||||
voiceBandPassFilterEnabled == null &&
|
||||
voiceCompressorEnabled == null &&
|
||||
voiceLimiterEnabled == null &&
|
||||
voiceAutoGainEnabled == null &&
|
||||
voiceEchoCancellationEnabled == null &&
|
||||
voiceNoiseSuppressionEnabled == null &&
|
||||
messageFontScale == null &&
|
||||
autoRouteRotationEnabled == null &&
|
||||
clearPathOnMaxRetry == null &&
|
||||
nearestRelayFallbackEnabled == null &&
|
||||
voiceBitrate == null &&
|
||||
routeHashSize == null &&
|
||||
imageMaxSize == null &&
|
||||
imageCompression == null &&
|
||||
imageGrayscale == null &&
|
||||
imageUltraMode == null &&
|
||||
showRxTxIndicators == null &&
|
||||
fastLocationUpdatesEnabled == null &&
|
||||
fastLocationMovementThresholdMeters == null &&
|
||||
fastLocationActiveCadenceSeconds == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapEnabled': mapEnabled,
|
||||
'contactsEnabled': contactsEnabled,
|
||||
'sensorsEnabled': sensorsEnabled,
|
||||
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
|
||||
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
|
||||
'voiceCompressorEnabled': voiceCompressorEnabled,
|
||||
'voiceLimiterEnabled': voiceLimiterEnabled,
|
||||
'voiceAutoGainEnabled': voiceAutoGainEnabled,
|
||||
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
|
||||
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
|
||||
'messageFontScale': messageFontScale,
|
||||
'autoRouteRotationEnabled': autoRouteRotationEnabled,
|
||||
'clearPathOnMaxRetry': clearPathOnMaxRetry,
|
||||
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
|
||||
'voiceBitrate': voiceBitrate,
|
||||
'routeHashSize': routeHashSize,
|
||||
'imageMaxSize': imageMaxSize,
|
||||
'imageCompression': imageCompression,
|
||||
'imageGrayscale': imageGrayscale,
|
||||
'imageUltraMode': imageUltraMode,
|
||||
'showRxTxIndicators': showRxTxIndicators,
|
||||
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
|
||||
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
|
||||
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
|
||||
};
|
||||
|
||||
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: json['mapEnabled'] as bool?,
|
||||
contactsEnabled: json['contactsEnabled'] as bool?,
|
||||
sensorsEnabled: json['sensorsEnabled'] as bool?,
|
||||
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
|
||||
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
|
||||
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
|
||||
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
|
||||
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
|
||||
voiceEchoCancellationEnabled:
|
||||
json['voiceEchoCancellationEnabled'] as bool?,
|
||||
voiceNoiseSuppressionEnabled:
|
||||
json['voiceNoiseSuppressionEnabled'] as bool?,
|
||||
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
|
||||
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
|
||||
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
|
||||
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
|
||||
voiceBitrate: json['voiceBitrate'] as int?,
|
||||
routeHashSize: json['routeHashSize'] as int?,
|
||||
imageMaxSize: json['imageMaxSize'] as int?,
|
||||
imageCompression: json['imageCompression'] as int?,
|
||||
imageGrayscale: json['imageGrayscale'] as bool?,
|
||||
imageUltraMode: json['imageUltraMode'] as bool?,
|
||||
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
|
||||
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
|
||||
fastLocationMovementThresholdMeters:
|
||||
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
|
||||
fastLocationActiveCadenceSeconds:
|
||||
json['fastLocationActiveCadenceSeconds'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class MapWorkspaceProfileSection {
|
||||
final Map<String, dynamic>? mapPrefs;
|
||||
final List<Map<String, dynamic>> drawings;
|
||||
final Map<String, dynamic>? currentTrail;
|
||||
final List<Map<String, dynamic>> trailHistory;
|
||||
final Map<String, dynamic>? importedTrail;
|
||||
final bool? isTrailVisible;
|
||||
final bool? showCadastralOverlay;
|
||||
final bool? showForestRoadsOverlay;
|
||||
final bool? showHikingTrailsOverlay;
|
||||
final bool? showMainRoadsOverlay;
|
||||
final bool? showHouseNumbersOverlay;
|
||||
final bool? showFireHazardZonesOverlay;
|
||||
final bool? showHistoricalFiresOverlay;
|
||||
final bool? showFirebreaksOverlay;
|
||||
final bool? showKrasFireZonesOverlay;
|
||||
final bool? showPlaceNamesOverlay;
|
||||
final bool? showMunicipalityBordersOverlay;
|
||||
final bool? showAllContactTrails;
|
||||
final bool? hideRepeatersOnMap;
|
||||
|
||||
const MapWorkspaceProfileSection({
|
||||
this.mapPrefs,
|
||||
this.drawings = const [],
|
||||
this.currentTrail,
|
||||
this.trailHistory = const [],
|
||||
this.importedTrail,
|
||||
this.isTrailVisible,
|
||||
this.showCadastralOverlay,
|
||||
this.showForestRoadsOverlay,
|
||||
this.showHikingTrailsOverlay,
|
||||
this.showMainRoadsOverlay,
|
||||
this.showHouseNumbersOverlay,
|
||||
this.showFireHazardZonesOverlay,
|
||||
this.showHistoricalFiresOverlay,
|
||||
this.showFirebreaksOverlay,
|
||||
this.showKrasFireZonesOverlay,
|
||||
this.showPlaceNamesOverlay,
|
||||
this.showMunicipalityBordersOverlay,
|
||||
this.showAllContactTrails,
|
||||
this.hideRepeatersOnMap,
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
mapPrefs == null &&
|
||||
drawings.isEmpty &&
|
||||
currentTrail == null &&
|
||||
trailHistory.isEmpty &&
|
||||
importedTrail == null &&
|
||||
isTrailVisible == null &&
|
||||
showCadastralOverlay == null &&
|
||||
showForestRoadsOverlay == null &&
|
||||
showHikingTrailsOverlay == null &&
|
||||
showMainRoadsOverlay == null &&
|
||||
showHouseNumbersOverlay == null &&
|
||||
showFireHazardZonesOverlay == null &&
|
||||
showHistoricalFiresOverlay == null &&
|
||||
showFirebreaksOverlay == null &&
|
||||
showKrasFireZonesOverlay == null &&
|
||||
showPlaceNamesOverlay == null &&
|
||||
showMunicipalityBordersOverlay == null &&
|
||||
showAllContactTrails == null &&
|
||||
hideRepeatersOnMap == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'mapPrefs': mapPrefs,
|
||||
'drawings': drawings,
|
||||
'currentTrail': currentTrail,
|
||||
'trailHistory': trailHistory,
|
||||
'importedTrail': importedTrail,
|
||||
'isTrailVisible': isTrailVisible,
|
||||
'showCadastralOverlay': showCadastralOverlay,
|
||||
'showForestRoadsOverlay': showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
|
||||
'showAllContactTrails': showAllContactTrails,
|
||||
'hideRepeatersOnMap': hideRepeatersOnMap,
|
||||
};
|
||||
|
||||
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
|
||||
List<Map<String, dynamic>> decodeList(String key) {
|
||||
final raw = json[key] as List<dynamic>? ?? const [];
|
||||
return raw.whereType<Map<String, dynamic>>().toList();
|
||||
}
|
||||
|
||||
return MapWorkspaceProfileSection(
|
||||
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
|
||||
drawings: decodeList('drawings'),
|
||||
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
|
||||
trailHistory: decodeList('trailHistory'),
|
||||
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
|
||||
isTrailVisible: json['isTrailVisible'] as bool?,
|
||||
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
|
||||
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
|
||||
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
|
||||
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
|
||||
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
|
||||
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
|
||||
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
|
||||
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
|
||||
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
|
||||
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
|
||||
showMunicipalityBordersOverlay:
|
||||
json['showMunicipalityBordersOverlay'] as bool?,
|
||||
showAllContactTrails: json['showAllContactTrails'] as bool?,
|
||||
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfileSections {
|
||||
final DeviceConfigProfileSection? deviceConfig;
|
||||
final AppSettingsProfileSection? appSettings;
|
||||
final MapWorkspaceProfileSection? mapWorkspace;
|
||||
final List<Channel> channels;
|
||||
|
||||
const ConfigProfileSections({
|
||||
this.deviceConfig,
|
||||
this.appSettings,
|
||||
this.mapWorkspace,
|
||||
this.channels = const [],
|
||||
});
|
||||
|
||||
bool get isEmpty =>
|
||||
(deviceConfig == null || deviceConfig!.isEmpty) &&
|
||||
(appSettings == null || appSettings!.isEmpty) &&
|
||||
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
|
||||
channels.isEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'deviceConfig': deviceConfig?.toJson(),
|
||||
'appSettings': appSettings?.toJson(),
|
||||
'mapWorkspace': mapWorkspace?.toJson(),
|
||||
'channels': channels.map((channel) => channel.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
|
||||
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
|
||||
? DeviceConfigProfileSection.fromJson(
|
||||
json['deviceConfig'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
appSettings: json['appSettings'] is Map<String, dynamic>
|
||||
? AppSettingsProfileSection.fromJson(
|
||||
json['appSettings'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
|
||||
? MapWorkspaceProfileSection.fromJson(
|
||||
json['mapWorkspace'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
channels: rawChannels
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Channel.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ConfigProfile {
|
||||
static const String defaultProfileId = 'default';
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final String? notes;
|
||||
final ConfigProfileSections sections;
|
||||
|
||||
const ConfigProfile({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.sections,
|
||||
this.notes,
|
||||
});
|
||||
|
||||
bool get isDefault => id == defaultProfileId;
|
||||
|
||||
factory ConfigProfile.defaultProfile() {
|
||||
final now = DateTime.now();
|
||||
return ConfigProfile(
|
||||
id: defaultProfileId,
|
||||
name: 'Default',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sections: const ConfigProfileSections(),
|
||||
);
|
||||
}
|
||||
|
||||
ConfigProfile copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
String? notes,
|
||||
ConfigProfileSections? sections,
|
||||
}) {
|
||||
return ConfigProfile(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
notes: notes ?? this.notes,
|
||||
sections: sections ?? this.sections,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'notes': notes,
|
||||
'sections': sections.toJson(),
|
||||
};
|
||||
|
||||
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
|
||||
return ConfigProfile(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
notes: json['notes'] as String?,
|
||||
sections: ConfigProfileSections.fromJson(
|
||||
json['sections'] as Map<String, dynamic>? ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileDiff {
|
||||
final List<String> changedSections;
|
||||
|
||||
const ProfileDiff({required this.changedSections});
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProfileTransferRecord {
|
||||
final String profileId;
|
||||
final String direction;
|
||||
final DateTime timestamp;
|
||||
final String detail;
|
||||
|
||||
const ProfileTransferRecord({
|
||||
required this.profileId,
|
||||
required this.direction,
|
||||
required this.timestamp,
|
||||
required this.detail,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'profileId': profileId,
|
||||
'direction': direction,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'detail': detail,
|
||||
};
|
||||
|
||||
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
|
||||
return ProfileTransferRecord(
|
||||
profileId: json['profileId'] as String,
|
||||
direction: json['direction'] as String,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
detail: json['detail'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,23 @@ class ContactRouteCodec {
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute direct({int hashSize = 1}) {
|
||||
if (hashSize < 1 || hashSize > maxHashSize) {
|
||||
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
|
||||
}
|
||||
|
||||
final encodedPathLen = (hashSize - 1) << 6;
|
||||
return ParsedContactRoute(
|
||||
canonicalText: '',
|
||||
hashSize: hashSize,
|
||||
hopCount: 0,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: Uint8List(0),
|
||||
paddedPathBytes: Uint8List(maxPathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute? fromContact(Contact contact) {
|
||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||
return null;
|
||||
@@ -232,7 +249,7 @@ extension ContactLocalization on Contact {
|
||||
|
||||
String get routeSummary {
|
||||
if (routeIsUnknown) {
|
||||
return 'Unknown';
|
||||
return 'Flood/Unknown';
|
||||
}
|
||||
if (!routeHasPath || routeHopCount == 0) {
|
||||
return 'Direct';
|
||||
@@ -240,6 +257,5 @@ extension ContactLocalization on Contact {
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport =>
|
||||
routeHasPath && routeSignedPathLen >= 0;
|
||||
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
151
lib/models/custom_map_config.dart
Normal file
151
lib/models/custom_map_config.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class CustomMapConfig {
|
||||
final String filePath;
|
||||
final String displayName;
|
||||
final String mapId;
|
||||
final int imageWidth;
|
||||
final int imageHeight;
|
||||
final double? metersPerPixel;
|
||||
final LatLng? calibrationPointA;
|
||||
final LatLng? calibrationPointB;
|
||||
|
||||
const CustomMapConfig({
|
||||
required this.filePath,
|
||||
required this.displayName,
|
||||
required this.mapId,
|
||||
required this.imageWidth,
|
||||
required this.imageHeight,
|
||||
this.metersPerPixel,
|
||||
this.calibrationPointA,
|
||||
this.calibrationPointB,
|
||||
});
|
||||
|
||||
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
||||
|
||||
double get _displayScale {
|
||||
final heightScale = imageHeight / 90.0;
|
||||
final widthScale = imageWidth / 180.0;
|
||||
return math.max(1.0, math.max(heightScale, widthScale));
|
||||
}
|
||||
|
||||
double get _displayHeight => imageHeight / _displayScale;
|
||||
|
||||
double get _displayWidth => imageWidth / _displayScale;
|
||||
|
||||
LatLngBounds get bounds =>
|
||||
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
|
||||
|
||||
LatLngBounds get displayBounds =>
|
||||
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
|
||||
|
||||
LatLng toDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
_displayHeight - (point.latitude / _displayScale),
|
||||
point.longitude / _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
LatLng fromDisplayPoint(LatLng point) {
|
||||
return LatLng(
|
||||
(_displayHeight - point.latitude) * _displayScale,
|
||||
point.longitude * _displayScale,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'filePath': filePath,
|
||||
'displayName': displayName,
|
||||
'mapId': mapId,
|
||||
'imageWidth': imageWidth,
|
||||
'imageHeight': imageHeight,
|
||||
'metersPerPixel': metersPerPixel,
|
||||
'calibrationPointA': calibrationPointA != null
|
||||
? {
|
||||
'lat': calibrationPointA!.latitude,
|
||||
'lon': calibrationPointA!.longitude,
|
||||
}
|
||||
: null,
|
||||
'calibrationPointB': calibrationPointB != null
|
||||
? {
|
||||
'lat': calibrationPointB!.latitude,
|
||||
'lon': calibrationPointB!.longitude,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
CustomMapConfig copyWith({
|
||||
String? filePath,
|
||||
String? displayName,
|
||||
String? mapId,
|
||||
int? imageWidth,
|
||||
int? imageHeight,
|
||||
double? metersPerPixel,
|
||||
bool clearMetersPerPixel = false,
|
||||
LatLng? calibrationPointA,
|
||||
bool clearCalibrationPointA = false,
|
||||
LatLng? calibrationPointB,
|
||||
bool clearCalibrationPointB = false,
|
||||
}) {
|
||||
return CustomMapConfig(
|
||||
filePath: filePath ?? this.filePath,
|
||||
displayName: displayName ?? this.displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
|
||||
imageWidth: imageWidth ?? this.imageWidth,
|
||||
imageHeight: imageHeight ?? this.imageHeight,
|
||||
metersPerPixel: clearMetersPerPixel
|
||||
? null
|
||||
: (metersPerPixel ?? this.metersPerPixel),
|
||||
calibrationPointA: clearCalibrationPointA
|
||||
? null
|
||||
: (calibrationPointA ?? this.calibrationPointA),
|
||||
calibrationPointB: clearCalibrationPointB
|
||||
? null
|
||||
: (calibrationPointB ?? this.calibrationPointB),
|
||||
);
|
||||
}
|
||||
|
||||
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
|
||||
final filePath = json['filePath'];
|
||||
final displayName = json['displayName'];
|
||||
final mapId = json['mapId'];
|
||||
final imageWidth = json['imageWidth'];
|
||||
final imageHeight = json['imageHeight'];
|
||||
if (filePath is! String ||
|
||||
displayName is! String ||
|
||||
mapId is! String ||
|
||||
imageWidth is! int ||
|
||||
imageHeight is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LatLng? parsePoint(dynamic raw) {
|
||||
if (raw is! Map<String, dynamic>) return null;
|
||||
final lat = raw['lat'];
|
||||
final lon = raw['lon'];
|
||||
if (lat is! num || lon is! num) return null;
|
||||
return LatLng(lat.toDouble(), lon.toDouble());
|
||||
}
|
||||
|
||||
final metersPerPixel = json['metersPerPixel'];
|
||||
return CustomMapConfig(
|
||||
filePath: filePath,
|
||||
displayName: displayName,
|
||||
mapId: normalizeCustomMapId(mapId) ?? mapId,
|
||||
imageWidth: imageWidth,
|
||||
imageHeight: imageHeight,
|
||||
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
|
||||
calibrationPointA: parsePoint(json['calibrationPointA']),
|
||||
calibrationPointB: parsePoint(json['calibrationPointB']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ enum ConnectionMode {
|
||||
/// Direct BLE connection to MeshCore device (default)
|
||||
ble,
|
||||
|
||||
/// Act as SSE server - share BLE device with multiple clients
|
||||
sseServer,
|
||||
|
||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||
tcp,
|
||||
|
||||
/// USB serial connection (Android OTG or Web Serial API)
|
||||
usb,
|
||||
}
|
||||
|
||||
extension ConnectionModeExtension on ConnectionMode {
|
||||
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct (BLE)';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share Device (Server)';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct (WiFi)';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct (Serial)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
||||
switch (this) {
|
||||
case ConnectionMode.ble:
|
||||
return 'Direct BLE connection to MeshCore device';
|
||||
case ConnectionMode.sseServer:
|
||||
return 'Share BLE device with multiple clients over network';
|
||||
case ConnectionMode.tcp:
|
||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||
case ConnectionMode.usb:
|
||||
return 'Direct serial connection to MeshCore device';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ class DeviceInfo {
|
||||
final int? advLat;
|
||||
final int? advLon;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final int? radioFreq;
|
||||
final int? radioBw;
|
||||
final int? radioSf;
|
||||
@@ -113,6 +118,11 @@ class DeviceInfo {
|
||||
this.advLat,
|
||||
this.advLon,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.radioFreq,
|
||||
this.radioBw,
|
||||
this.radioSf,
|
||||
@@ -239,6 +249,11 @@ class DeviceInfo {
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
bool? manualAddContacts,
|
||||
bool? autoAddUsers,
|
||||
bool? autoAddRepeaters,
|
||||
bool? autoAddRoomServers,
|
||||
bool? autoAddSensors,
|
||||
bool? autoAddOverwriteOldest,
|
||||
int? radioFreq,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
@@ -278,6 +293,12 @@ class DeviceInfo {
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
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,
|
||||
radioBw: radioBw ?? this.radioBw,
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
|
||||
11
lib/models/map_coordinate_space.dart
Normal file
11
lib/models/map_coordinate_space.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum MapCoordinateSpace {
|
||||
geo,
|
||||
customMap;
|
||||
|
||||
static MapCoordinateSpace fromName(String? value) {
|
||||
return MapCoordinateSpace.values.firstWhere(
|
||||
(space) => space.name == value,
|
||||
orElse: () => MapCoordinateSpace.geo,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
/// Drawing shape type
|
||||
enum DrawingShapeType {
|
||||
line,
|
||||
rectangle,
|
||||
}
|
||||
enum DrawingShapeType { line, rectangle }
|
||||
|
||||
/// Drawing color enum for compact network transmission
|
||||
enum DrawingColor {
|
||||
red, // 0
|
||||
blue, // 1
|
||||
green, // 2
|
||||
yellow, // 3
|
||||
orange, // 4
|
||||
purple, // 5
|
||||
pink, // 6
|
||||
cyan, // 7
|
||||
}
|
||||
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
|
||||
|
||||
/// Drawing colors available for user selection
|
||||
class DrawingColors {
|
||||
static const List<Color> palette = [
|
||||
Colors.red, // index 0
|
||||
Colors.blue, // index 1
|
||||
Colors.green, // index 2
|
||||
Colors.yellow, // index 3
|
||||
Colors.orange, // index 4
|
||||
Colors.purple, // index 5
|
||||
Colors.pink, // index 6
|
||||
Colors.cyan, // index 7
|
||||
Colors.red,
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.yellow,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.cyan,
|
||||
];
|
||||
|
||||
static String colorToName(Color color) {
|
||||
@@ -45,37 +36,36 @@ class DrawingColors {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/// Convert Color to enum index for network transmission
|
||||
static int colorToIndex(Color color) {
|
||||
for (int i = 0; i < palette.length; i++) {
|
||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0; // Default to red if not found
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Convert enum index to Color for network reception
|
||||
static Color indexToColor(int index) {
|
||||
if (index >= 0 && index < palette.length) {
|
||||
return palette[index];
|
||||
}
|
||||
return palette[0]; // Default to red if invalid index
|
||||
return palette[0];
|
||||
}
|
||||
}
|
||||
|
||||
/// Base class for map drawings
|
||||
abstract class MapDrawing {
|
||||
final String id;
|
||||
final DrawingShapeType type;
|
||||
final Color color;
|
||||
final DateTime createdAt;
|
||||
final String? senderName; // Name of sender (null if local drawing)
|
||||
final bool isReceived; // True if drawing was received from another node
|
||||
final String? messageId; // ID of the source message (for navigation)
|
||||
final bool isShared; // Whether drawing has been broadcast over mesh
|
||||
final bool isSent; // Whether this is a sent drawing (vs received)
|
||||
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
|
||||
final String? senderName;
|
||||
final bool isReceived;
|
||||
final String? messageId;
|
||||
final bool isShared;
|
||||
final bool isSent;
|
||||
final bool isHidden;
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
MapDrawing({
|
||||
required this.id,
|
||||
@@ -88,79 +78,79 @@ abstract class MapDrawing {
|
||||
this.isShared = false,
|
||||
this.isSent = false,
|
||||
this.isHidden = false,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
/// Convert to JSON for persistence
|
||||
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// Convert to JSON for network transmission (compact format)
|
||||
/// Uses short field names and excludes createdAt to minimize message size
|
||||
/// Sender will be fetched from packet metadata
|
||||
Map<String, dynamic> toNetworkJson();
|
||||
|
||||
/// Parse network JSON (compact format)
|
||||
/// senderName and messageId will be populated from packet metadata
|
||||
static MapDrawing? fromNetworkJson(
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
final typeNum = json['t'] as int?;
|
||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
||||
if (typeNum == null ||
|
||||
typeNum < 0 ||
|
||||
typeNum >= DrawingShapeType.values.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values[typeNum];
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromNetworkJson(
|
||||
json,
|
||||
senderName: senderName,
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: mapId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String?;
|
||||
if (typeStr == null) return null;
|
||||
|
||||
try {
|
||||
final type = DrawingShapeType.values.firstWhere(
|
||||
(e) => e.toString() == 'DrawingShapeType.$typeStr',
|
||||
(value) => value.name == typeStr,
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case DrawingShapeType.line:
|
||||
return LineDrawing.fromJson(json);
|
||||
case DrawingShapeType.rectangle:
|
||||
return RectangleDrawing.fromJson(json);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the center point of the drawing
|
||||
LatLng getCenter();
|
||||
|
||||
/// Get the bounds of the drawing
|
||||
LatLngBounds getBounds();
|
||||
}
|
||||
|
||||
/// Line drawing on map
|
||||
class LineDrawing extends MapDrawing {
|
||||
final List<LatLng> points;
|
||||
|
||||
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
super.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.line);
|
||||
|
||||
@override
|
||||
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
|
||||
'type': type.name,
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'points': points
|
||||
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
|
||||
.toList(),
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
||||
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'p': points.expand((p) => [
|
||||
double.parse(p.latitude.toStringAsFixed(5)),
|
||||
double.parse(p.longitude.toStringAsFixed(5)),
|
||||
]).toList(),
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['p'] = points
|
||||
.expand((point) => [point.latitude.round(), point.longitude.round()])
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['p'] = points
|
||||
.expand(
|
||||
(point) => [
|
||||
double.parse(point.latitude.toStringAsFixed(5)),
|
||||
double.parse(point.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
return payload;
|
||||
}
|
||||
|
||||
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||
final pointsJson = json['points'] as List<dynamic>;
|
||||
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
|
||||
final points = pointsJson
|
||||
.map(
|
||||
(point) => LatLng(
|
||||
(point['lat'] as num).toDouble(),
|
||||
(point['lon'] as num).toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final senderName = json['sender'] as String?;
|
||||
|
||||
return LineDrawing(
|
||||
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isReceived: senderName != null,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing {
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
||||
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
|
||||
final points = <LatLng>[];
|
||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
||||
for (int i = 0; i < flatPoints.length; i += 2) {
|
||||
points.add(
|
||||
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
|
||||
);
|
||||
}
|
||||
|
||||
return LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
points: points,
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated points
|
||||
LineDrawing copyWith({List<LatLng>? points}) {
|
||||
LineDrawing copyWith({
|
||||
List<LatLng>? points,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return LineDrawing(
|
||||
id: id,
|
||||
color: color,
|
||||
createdAt: createdAt,
|
||||
points: points ?? this.points,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
if (points.isEmpty) return LatLng(0, 0);
|
||||
if (points.isEmpty) return const LatLng(0, 0);
|
||||
if (points.length == 1) return points[0];
|
||||
|
||||
// Calculate center as average of all points
|
||||
double sumLat = 0;
|
||||
double sumLon = 0;
|
||||
for (final point in points) {
|
||||
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
|
||||
if (points.length == 1) return LatLngBounds(points[0], points[0]);
|
||||
if (points.isEmpty) {
|
||||
return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0));
|
||||
}
|
||||
if (points.length == 1) {
|
||||
return LatLngBounds(points[0], points[0]);
|
||||
}
|
||||
|
||||
double minLat = points[0].latitude;
|
||||
double maxLat = points[0].latitude;
|
||||
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangle drawing on map
|
||||
class RectangleDrawing extends MapDrawing {
|
||||
final LatLng topLeft;
|
||||
final LatLng bottomRight;
|
||||
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
|
||||
super.isShared,
|
||||
super.isSent,
|
||||
super.isHidden,
|
||||
super.coordinateSpace,
|
||||
super.mapId,
|
||||
}) : super(type: DrawingShapeType.rectangle);
|
||||
|
||||
/// Get all corner points for rendering
|
||||
List<LatLng> get corners => [
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude), // top right
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
|
||||
topLeft, // close the rectangle
|
||||
];
|
||||
topLeft,
|
||||
LatLng(topLeft.latitude, bottomRight.longitude),
|
||||
bottomRight,
|
||||
LatLng(bottomRight.latitude, topLeft.longitude),
|
||||
topLeft,
|
||||
];
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
|
||||
'color': color.toARGB32(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
||||
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
|
||||
'bottomRight': {
|
||||
'lat': bottomRight.latitude,
|
||||
'lon': bottomRight.longitude,
|
||||
},
|
||||
'isShared': isShared,
|
||||
// Note: isHidden is not persisted - it's session-only
|
||||
'coordinateSpace': coordinateSpace.name,
|
||||
'mapId': normalizeCustomMapId(mapId),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toNetworkJson() {
|
||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
||||
// Sender is fetched from packet metadata, not included in JSON
|
||||
return {
|
||||
final payload = <String, dynamic>{
|
||||
't': type.index,
|
||||
'c': DrawingColors.colorToIndex(color),
|
||||
'b': [
|
||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||
],
|
||||
};
|
||||
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
payload['m'] = normalizeCustomMapId(mapId);
|
||||
payload['b'] = [
|
||||
topLeft.latitude.round(),
|
||||
topLeft.longitude.round(),
|
||||
bottomRight.latitude.round(),
|
||||
bottomRight.longitude.round(),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload['b'] = [
|
||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||
];
|
||||
return payload;
|
||||
}
|
||||
|
||||
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
||||
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
|
||||
id: json['id'] as String,
|
||||
color: Color(json['color'] as int),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
|
||||
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
|
||||
topLeft: LatLng(
|
||||
(topLeftJson['lat'] as num).toDouble(),
|
||||
(topLeftJson['lon'] as num).toDouble(),
|
||||
),
|
||||
bottomRight: LatLng(
|
||||
(bottomRightJson['lat'] as num).toDouble(),
|
||||
(bottomRightJson['lon'] as num).toDouble(),
|
||||
),
|
||||
senderName: senderName,
|
||||
isReceived: senderName != null, // Mark as received if sender is present
|
||||
isReceived: senderName != null,
|
||||
isShared: json['isShared'] as bool? ?? false,
|
||||
coordinateSpace: MapCoordinateSpace.fromName(
|
||||
json['coordinateSpace'] as String?,
|
||||
),
|
||||
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing {
|
||||
Map<String, dynamic> json, {
|
||||
String? senderName,
|
||||
String? messageId,
|
||||
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||
String? mapId,
|
||||
}) {
|
||||
// Parse ultra-compact format
|
||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
||||
final bounds = (json['b'] as List<dynamic>).cast<num>();
|
||||
|
||||
return RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: DrawingColors.indexToColor(json['c'] as int),
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(bounds[0], bounds[1]),
|
||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
||||
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
|
||||
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
|
||||
senderName: senderName,
|
||||
isReceived: true,
|
||||
messageId: messageId, // Link to source message
|
||||
isShared: false, // Received drawings are not marked as shared
|
||||
messageId: messageId,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: normalizeCustomMapId(mapId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated corners
|
||||
RectangleDrawing copyWith({
|
||||
LatLng? topLeft,
|
||||
LatLng? bottomRight,
|
||||
bool? isHidden,
|
||||
bool? isShared,
|
||||
bool? isReceived,
|
||||
String? messageId,
|
||||
String? senderName,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return RectangleDrawing(
|
||||
id: id,
|
||||
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
|
||||
createdAt: createdAt,
|
||||
topLeft: topLeft ?? this.topLeft,
|
||||
bottomRight: bottomRight ?? this.bottomRight,
|
||||
isHidden: isHidden ?? this.isHidden,
|
||||
isShared: isShared ?? this.isShared,
|
||||
isReceived: isReceived ?? this.isReceived,
|
||||
messageId: messageId ?? this.messageId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
LatLng getCenter() {
|
||||
// Center is the midpoint between top-left and bottom-right
|
||||
return LatLng(
|
||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
|
||||
|
||||
@override
|
||||
LatLngBounds getBounds() {
|
||||
// Bounds are simply the two corners
|
||||
return LatLngBounds(topLeft, bottomRight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'sar_marker.dart';
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
|
||||
extension MessageVoiceExtension on Message {
|
||||
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
|
||||
|
||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||
SarMarker? toSarMarker() {
|
||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
||||
if (!isSarMarker || sarMarkerType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
|
||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||
|
||||
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
|
||||
? MapCoordinateSpace.customMap
|
||||
: MapCoordinateSpace.geo;
|
||||
final location = sarCustomMapPoint ?? sarGpsCoordinates;
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SarMarker(
|
||||
id: id,
|
||||
type: sarMarkerType!,
|
||||
location: sarGpsCoordinates!,
|
||||
location: location,
|
||||
timestamp: sentAt,
|
||||
senderPublicKey: senderPublicKeyPrefix,
|
||||
senderName: senderName,
|
||||
notes: sarNotes,
|
||||
customEmoji: sarCustomEmoji,
|
||||
colorIndex: sarColorIndex,
|
||||
coordinateSpace: coordinateSpace,
|
||||
mapId: sarCustomMapId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
||||
final int? senderToReceiptMs;
|
||||
final int? estimatedTransmitMs;
|
||||
final int? postTransmitDelayMs;
|
||||
final int receivedCopies;
|
||||
|
||||
const MessageReceptionDetails({
|
||||
required this.capturedAt,
|
||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
||||
this.senderToReceiptMs,
|
||||
this.estimatedTransmitMs,
|
||||
this.postTransmitDelayMs,
|
||||
this.receivedCopies = 1,
|
||||
});
|
||||
|
||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
||||
'senderToReceiptMs': senderToReceiptMs,
|
||||
'estimatedTransmitMs': estimatedTransmitMs,
|
||||
'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) {
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (capturedAtMillis is! int) {
|
||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||
estimatedTransmitMs: json['estimatedTransmitMs'] 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'map_coordinate_space.dart';
|
||||
import '../services/sar_template_service.dart';
|
||||
|
||||
/// SAR (Search & Rescue) marker types
|
||||
@@ -78,6 +79,8 @@ class SarMarker {
|
||||
final String? notes;
|
||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||
final int? colorIndex; // Color index (0-7) from standard palette
|
||||
final MapCoordinateSpace coordinateSpace;
|
||||
final String? mapId;
|
||||
|
||||
SarMarker({
|
||||
required this.id,
|
||||
@@ -89,8 +92,13 @@ class SarMarker {
|
||||
this.notes,
|
||||
this.customEmoji,
|
||||
this.colorIndex,
|
||||
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||
this.mapId,
|
||||
});
|
||||
|
||||
bool get isCustomMapMarker =>
|
||||
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
|
||||
|
||||
/// Get sender public key as hex string (short)
|
||||
String? get senderKeyShort {
|
||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||
@@ -167,6 +175,8 @@ class SarMarker {
|
||||
String? notes,
|
||||
String? customEmoji,
|
||||
int? colorIndex,
|
||||
MapCoordinateSpace? coordinateSpace,
|
||||
String? mapId,
|
||||
}) {
|
||||
return SarMarker(
|
||||
id: id ?? this.id,
|
||||
@@ -178,6 +188,8 @@ class SarMarker {
|
||||
notes: notes ?? this.notes,
|
||||
customEmoji: customEmoji ?? this.customEmoji,
|
||||
colorIndex: colorIndex ?? this.colorIndex,
|
||||
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||
mapId: mapId ?? this.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,8 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
|
||||
/// Drawing mode state
|
||||
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
||||
/// Provider for managing map drawings
|
||||
class DrawingProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'map_drawings';
|
||||
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
// Completed drawings
|
||||
final List<MapDrawing> _drawings = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
// In-progress drawing
|
||||
MapDrawing? _currentDrawing;
|
||||
@@ -30,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
|
||||
LatLng? _measurementPoint1;
|
||||
LatLng? _measurementPoint2;
|
||||
double? _measuredDistance; // in meters
|
||||
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _activeMapId;
|
||||
double? _activeMetersPerPixel;
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
Color get selectedColor => _selectedColor;
|
||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||
bool get showSarMarkers => _showSarMarkers;
|
||||
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||
String? get activeMapId => _activeMapId;
|
||||
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||
List<MapDrawing> get drawings {
|
||||
// Filter out hidden drawings first
|
||||
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
||||
@@ -45,19 +57,62 @@ class DrawingProvider with ChangeNotifier {
|
||||
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
||||
}
|
||||
|
||||
visibleDrawings = visibleDrawings.where((drawing) {
|
||||
if (drawing.coordinateSpace != _activeCoordinateSpace) {
|
||||
return false;
|
||||
}
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return drawing.mapId != null && drawing.mapId == _activeMapId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return List.unmodifiable(visibleDrawings.toList());
|
||||
}
|
||||
|
||||
MapDrawing? get currentDrawing => _currentDrawing;
|
||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||
bool get isInitialized => _isInitialized;
|
||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||
double? get measuredDistance => _measuredDistance;
|
||||
|
||||
/// Initialize and load saved drawings
|
||||
Future<void> initialize() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMapContext({
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double? metersPerPixel,
|
||||
}) {
|
||||
final changed =
|
||||
coordinateSpace != _activeCoordinateSpace ||
|
||||
mapId != _activeMapId ||
|
||||
metersPerPixel != _activeMetersPerPixel;
|
||||
_activeCoordinateSpace = coordinateSpace;
|
||||
_activeMapId = mapId;
|
||||
_activeMetersPerPixel = metersPerPixel;
|
||||
if (_measurementPoint1 != null && _measurementPoint2 != null) {
|
||||
_measuredDistance = _calculateDistance(
|
||||
_measurementPoint1!,
|
||||
_measurementPoint2!,
|
||||
);
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set drawing mode
|
||||
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Toggle visibility of received drawings
|
||||
void toggleReceivedDrawings() {
|
||||
Future<void> toggleReceivedDrawings() async {
|
||||
_showReceivedDrawings = !_showReceivedDrawings;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
/// Toggle visibility of SAR markers
|
||||
void toggleSarMarkers() {
|
||||
Future<void> toggleSarMarkers() async {
|
||||
_showSarMarkers = !_showSarMarkers;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings =
|
||||
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey(_showReceivedDrawingsKey),
|
||||
_showReceivedDrawings,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||
}
|
||||
|
||||
/// Start drawing a line
|
||||
@@ -116,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: List.from(_currentLinePoints),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -195,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: topLeft,
|
||||
bottomRight: bottomRight,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
@@ -216,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Set second measurement point and calculate distance
|
||||
void setMeasurementPoint2(LatLng point) {
|
||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
|
||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_measurementPoint2 = point;
|
||||
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
||||
@@ -225,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
/// Calculate distance between two points using Haversine formula
|
||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
||||
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
|
||||
_activeMetersPerPixel != null) {
|
||||
final dy = point2.latitude - point1.latitude;
|
||||
final dx = point2.longitude - point1.longitude;
|
||||
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
|
||||
return pixelDistance * _activeMetersPerPixel!;
|
||||
}
|
||||
const Distance distance = Distance();
|
||||
return distance.as(LengthUnit.Meter, point1, point2);
|
||||
}
|
||||
@@ -281,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||
final jsonString = jsonEncode(jsonList);
|
||||
await prefs.setString(_storageKey, jsonString);
|
||||
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving drawings: $e');
|
||||
}
|
||||
@@ -291,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
|
||||
Future<void> _loadDrawings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_storageKey);
|
||||
if (jsonString == null) return;
|
||||
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_drawings.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
_drawings.clear();
|
||||
@@ -318,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: _currentLinePoints,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||
_currentDrawing != null) {
|
||||
@@ -355,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
return RectangleDrawing(
|
||||
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
return drawing;
|
||||
@@ -383,9 +489,27 @@ class DrawingProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> exportDrawingsJson() {
|
||||
return _drawings.map((drawing) => drawing.toJson()).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceDrawingsFromJson(
|
||||
List<Map<String, dynamic>> jsonList,
|
||||
) async {
|
||||
_drawings
|
||||
..clear()
|
||||
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
|
||||
await _saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
/// Get all unshared drawings (local drawings not yet sent)
|
||||
List<MapDrawing> getUnsharedDrawings() {
|
||||
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
}
|
||||
|
||||
/// Mark a drawing as shared
|
||||
@@ -407,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
@@ -462,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
||||
/// Map of ACK tag to timestamp for timeout cleanup
|
||||
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
|
||||
///
|
||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
|
||||
_messageIdToAckTag.remove(messageId);
|
||||
}
|
||||
_ackTagTimestamps.remove(ackCode);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping by message ID
|
||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
||||
if (ackTag != null) {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Get count of pending ACK tags
|
||||
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
|
||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||
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)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - 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
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
@@ -21,10 +20,19 @@ class MessageRetryManager {
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
/// Max retry attempts when the contact has a known path.
|
||||
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without).
|
||||
static const int maxRetryAttemptsWithPath = 5;
|
||||
|
||||
/// No retries for flood-only contacts (no known path).
|
||||
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||
static const int maxRetryAttemptsFloodOnly = 0;
|
||||
|
||||
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
@@ -32,76 +40,65 @@ class MessageRetryManager {
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||
/// provide one or returns an invalid value.
|
||||
static final math.Random _rng = math.Random();
|
||||
|
||||
/// Calculate a delivery-ACK timeout with random jitter.
|
||||
///
|
||||
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||
/// The jitter prevents collision when multiple messages are in flight.
|
||||
int calculateAckTimeoutMs({
|
||||
required String text,
|
||||
required Contact? contact,
|
||||
int? suggestedTimeoutMs,
|
||||
}) {
|
||||
int baseTimeout;
|
||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||
return suggestedTimeoutMs;
|
||||
baseTimeout = suggestedTimeoutMs;
|
||||
} else {
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
} else {
|
||||
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
}
|
||||
|
||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
// Add random jitter: 500-2000ms to avoid collision
|
||||
final jitterMs = 500 + _rng.nextInt(1501);
|
||||
return baseTimeout + jitterMs;
|
||||
}
|
||||
|
||||
/// Max attempts for a given contact based on whether it has a known path.
|
||||
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||
final hasPath = contact?.routeHasPath ?? false;
|
||||
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
// Never retry if already tried flood mode
|
||||
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;
|
||||
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
/// Whether this is the last retry attempt.
|
||||
/// When true, the caller should reset the path to force 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;
|
||||
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||
/// directly against maxAttempts (not +1).
|
||||
bool isLastAttempt(Message message, Contact contact) {
|
||||
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
|
||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
);
|
||||
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) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
try {
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeSignedPathLen,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/custom_map_config.dart';
|
||||
import '../models/location_trail.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
static const String _customMapConfigKey = 'custom_map_config_v1';
|
||||
static const String _customMapModeKey = 'custom_map_mode_v1';
|
||||
static const String _customMapsDirName = 'custom_maps';
|
||||
|
||||
MapProvider() {
|
||||
unawaited(_loadInitialState());
|
||||
}
|
||||
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
LatLng? _targetLocation;
|
||||
LatLngBounds? _targetBounds;
|
||||
double? _targetZoom;
|
||||
bool _shouldAnimate = false;
|
||||
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _targetMapId;
|
||||
|
||||
// Track which contact paths are currently visible
|
||||
final Set<String> _visibleContactPaths = {};
|
||||
|
||||
// Location trail tracking
|
||||
LocationTrail? _currentTrail;
|
||||
bool _isTrailVisible = true;
|
||||
final List<LocationTrail> _trailHistory = [];
|
||||
|
||||
// WMS overlay toggles
|
||||
bool _showCadastralOverlay = false;
|
||||
bool _showForestRoadsOverlay = false;
|
||||
bool _showHikingTrailsOverlay = false;
|
||||
@@ -31,28 +55,30 @@ class MapProvider with ChangeNotifier {
|
||||
bool _showPlaceNamesOverlay = false;
|
||||
bool _showMunicipalityBordersOverlay = false;
|
||||
|
||||
// Contact trail toggles
|
||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
||||
bool _showAllContactTrails = true;
|
||||
bool _hideRepeatersOnMap = false;
|
||||
|
||||
// Imported trail (from GPX)
|
||||
LocationTrail? _importedTrail;
|
||||
|
||||
// Download area selection
|
||||
bool _isSelectingDownloadArea = false;
|
||||
LatLngBounds? _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? _customMapConfig;
|
||||
bool _isUsingCustomMap = false;
|
||||
|
||||
LatLng? get targetLocation => _targetLocation;
|
||||
LatLngBounds? get targetBounds => _targetBounds;
|
||||
double? get targetZoom => _targetZoom;
|
||||
bool get shouldAnimate => _shouldAnimate;
|
||||
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
|
||||
String? get targetMapId => _targetMapId;
|
||||
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
||||
|
||||
// Trail getters
|
||||
LocationTrail? get currentTrail => _currentTrail;
|
||||
bool get isTrailVisible => _isTrailVisible;
|
||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||
|
||||
// WMS overlay getters
|
||||
bool get showCadastralOverlay => _showCadastralOverlay;
|
||||
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
||||
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
||||
@@ -65,68 +91,139 @@ class MapProvider with ChangeNotifier {
|
||||
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
||||
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
||||
|
||||
// Contact trail getters
|
||||
bool get showAllContactTrails => _showAllContactTrails;
|
||||
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||
|
||||
// Imported trail getters
|
||||
LocationTrail? get importedTrail => _importedTrail;
|
||||
|
||||
// Download area getters
|
||||
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
||||
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? get customMapConfig => _customMapConfig;
|
||||
bool get hasCustomMap => _customMapConfig != null;
|
||||
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
|
||||
bool get shouldHideGpsData => isUsingCustomMap;
|
||||
|
||||
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadInitialState();
|
||||
}
|
||||
|
||||
bool matchesActiveCustomMap(String? mapId) {
|
||||
return hasCustomMap &&
|
||||
normalizeCustomMapId(_customMapConfig!.mapId) ==
|
||||
normalizeCustomMapId(mapId);
|
||||
}
|
||||
|
||||
void navigateToLocation({
|
||||
required LatLng location,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
_targetLocation = location;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? navigateToMapPoint({
|
||||
required LatLng point,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetLocation = point;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToBounds({
|
||||
required LatLngBounds bounds,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetBounds = bounds;
|
||||
_targetLocation = bounds.center;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
void clearNavigation() {
|
||||
_targetLocation = null;
|
||||
_targetBounds = null;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = false;
|
||||
// Don't notify listeners to avoid rebuilds
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
}
|
||||
|
||||
/// Navigate to a drawing by its ID
|
||||
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||
debugPrint('🗺️ [MapProvider] navigateToDrawing called with ID: $drawingId');
|
||||
// Find the drawing in the provider
|
||||
final drawings = drawingProvider.drawings as List;
|
||||
debugPrint('🗺️ [MapProvider] Total drawings in provider: ${drawings.length}');
|
||||
final drawing = drawings.cast<dynamic>().firstWhere(
|
||||
(d) => d.id == drawingId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
|
||||
if (drawing == null) {
|
||||
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
|
||||
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
|
||||
return;
|
||||
return 'Drawing not found.';
|
||||
}
|
||||
|
||||
// Use MapDrawing's built-in getCenter and getBounds methods
|
||||
final center = drawing.getCenter();
|
||||
final bounds = drawing.getBounds();
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(drawing.mapId)) {
|
||||
return 'Load the matching custom map to view this drawing.';
|
||||
}
|
||||
return navigateToBounds(
|
||||
bounds: drawing.getBounds(),
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate appropriate zoom level based on bounds
|
||||
// For larger drawings, use lower zoom to fit the whole drawing
|
||||
// For smaller drawings, use higher zoom for better detail
|
||||
final bounds = drawing.getBounds();
|
||||
final latDiff = (bounds.north - bounds.south).abs();
|
||||
final lonDiff = (bounds.east - bounds.west).abs();
|
||||
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
||||
|
||||
// Zoom scale: smaller drawings get higher zoom
|
||||
// 0.001 degrees (~100m) -> zoom 17
|
||||
// 0.005 degrees (~500m) -> zoom 16
|
||||
// 0.01 degrees (~1km) -> zoom 15
|
||||
// 0.05 degrees (~5km) -> zoom 13
|
||||
// 0.1 degrees (~10km) -> zoom 12
|
||||
double zoom = 15.0;
|
||||
if (maxDiff < 0.001) {
|
||||
zoom = 17.0;
|
||||
@@ -142,9 +239,125 @@ class MapProvider with ChangeNotifier {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
|
||||
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
|
||||
navigateToLocation(location: center, zoom: zoom, animate: true);
|
||||
navigateToLocation(
|
||||
location: drawing.getCenter(),
|
||||
zoom: zoom,
|
||||
animate: true,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToSarMarker(SarMarker marker) {
|
||||
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return navigateToMapPoint(
|
||||
point: marker.location,
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: marker.mapId,
|
||||
);
|
||||
}
|
||||
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> loadCustomMapFromGallery() async {
|
||||
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) {
|
||||
return false;
|
||||
}
|
||||
final bytes = await picked.readAsBytes();
|
||||
await setCustomMapImage(bytes: bytes, displayName: picked.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> replaceCustomMap() async {
|
||||
await loadCustomMapFromGallery();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapImage({
|
||||
required Uint8List bytes,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final dimensions = await _decodeImageSize(bytes);
|
||||
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
|
||||
final documentsDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final extension = _normalizedExtension(displayName);
|
||||
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
|
||||
final nextFile = File(nextPath);
|
||||
await nextFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
final previousPath = _customMapConfig?.filePath;
|
||||
_customMapConfig = CustomMapConfig(
|
||||
filePath: nextPath,
|
||||
displayName: displayName,
|
||||
mapId: mapId,
|
||||
imageWidth: dimensions.$1,
|
||||
imageHeight: dimensions.$2,
|
||||
);
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
|
||||
if (previousPath != null && previousPath != nextPath) {
|
||||
unawaited(_deleteFileIfExists(previousPath));
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapCalibration({
|
||||
required LatLng pointA,
|
||||
required LatLng pointB,
|
||||
required double metersPerPixel,
|
||||
}) async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
calibrationPointA: pointA,
|
||||
calibrationPointB: pointB,
|
||||
metersPerPixel: metersPerPixel,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearCustomMapCalibration() async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
clearMetersPerPixel: true,
|
||||
clearCalibrationPointA: true,
|
||||
clearCalibrationPointB: true,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeCustomMap() async {
|
||||
final filePath = _customMapConfig?.filePath;
|
||||
_customMapConfig = null;
|
||||
_isUsingCustomMap = false;
|
||||
clearNavigation();
|
||||
await _saveCustomMapState();
|
||||
if (filePath != null) {
|
||||
await _deleteFileIfExists(filePath);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> enterCustomMapMode() async {
|
||||
if (!hasCustomMap || _isUsingCustomMap) return;
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> exitCustomMapMode() async {
|
||||
if (!_isUsingCustomMap) return;
|
||||
_isUsingCustomMap = false;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateZoom(double zoom) {
|
||||
@@ -152,7 +365,6 @@ class MapProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle path visibility for a contact
|
||||
void toggleContactPath(String publicKeyHex) {
|
||||
if (_visibleContactPaths.contains(publicKeyHex)) {
|
||||
_visibleContactPaths.remove(publicKeyHex);
|
||||
@@ -162,27 +374,22 @@ class MapProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a contact's path is visible
|
||||
bool isContactPathVisible(String publicKeyHex) {
|
||||
return _visibleContactPaths.contains(publicKeyHex);
|
||||
}
|
||||
|
||||
/// Hide all contact paths
|
||||
void hideAllPaths() {
|
||||
_visibleContactPaths.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Show path for specific contact (hide all others)
|
||||
void showOnlyPath(String publicKeyHex) {
|
||||
_visibleContactPaths.clear();
|
||||
_visibleContactPaths.add(publicKeyHex);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start a new location trail
|
||||
void startTrail() {
|
||||
// End current trail if active
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
@@ -195,22 +402,22 @@ class MapProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add a point to the current trail
|
||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||
startTrail();
|
||||
}
|
||||
|
||||
_currentTrail!.addPoint(TrailPoint(
|
||||
position: position,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
));
|
||||
_currentTrail!.addPoint(
|
||||
TrailPoint(
|
||||
position: position,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// End the current trail
|
||||
void endTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail!.isActive = false;
|
||||
@@ -223,13 +430,11 @@ class MapProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle trail visibility
|
||||
void toggleTrailVisibility() {
|
||||
_isTrailVisible = !_isTrailVisible;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear the current trail
|
||||
void clearCurrentTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail = null;
|
||||
@@ -237,197 +442,362 @@ class MapProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all trail history
|
||||
void clearAllTrails() {
|
||||
_currentTrail = null;
|
||||
_trailHistory.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get total trail distance in meters
|
||||
double get totalTrailDistance {
|
||||
if (_currentTrail == null) return 0;
|
||||
return _currentTrail!.totalDistance;
|
||||
}
|
||||
|
||||
/// Get trail duration
|
||||
Duration get trailDuration {
|
||||
if (_currentTrail == null) return Duration.zero;
|
||||
return _currentTrail!.duration;
|
||||
}
|
||||
|
||||
/// Toggle cadastral parcels overlay
|
||||
Future<void> toggleCadastralOverlay() async {
|
||||
_showCadastralOverlay = !_showCadastralOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle forest roads overlay
|
||||
Future<void> toggleForestRoadsOverlay() async {
|
||||
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle hiking trails overlay
|
||||
Future<void> toggleHikingTrailsOverlay() async {
|
||||
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle main roads overlay
|
||||
Future<void> toggleMainRoadsOverlay() async {
|
||||
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle house numbers overlay
|
||||
Future<void> toggleHouseNumbersOverlay() async {
|
||||
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle fire hazard zones overlay
|
||||
Future<void> toggleFireHazardZonesOverlay() async {
|
||||
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle historical fires overlay
|
||||
Future<void> toggleHistoricalFiresOverlay() async {
|
||||
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle firebreaks overlay
|
||||
Future<void> toggleFirebreaksOverlay() async {
|
||||
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle Kras fire zones overlay
|
||||
Future<void> toggleKrasFireZonesOverlay() async {
|
||||
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle place names overlay
|
||||
Future<void> togglePlaceNamesOverlay() async {
|
||||
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Toggle municipality borders overlay
|
||||
Future<void> toggleMunicipalityBordersOverlay() async {
|
||||
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
/// Load overlay state from SharedPreferences
|
||||
Future<void> loadOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
|
||||
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
|
||||
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
|
||||
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
|
||||
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
|
||||
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
|
||||
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
|
||||
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
|
||||
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
|
||||
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
|
||||
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false;
|
||||
_showCadastralOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||
_showForestRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||
_showMainRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
|
||||
false;
|
||||
_showHistoricalFiresOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
|
||||
_showFirebreaksOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
|
||||
_showPlaceNamesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
|
||||
false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Save overlay state to SharedPreferences
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
|
||||
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
|
||||
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
|
||||
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
|
||||
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
|
||||
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
|
||||
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
|
||||
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
|
||||
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
|
||||
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
|
||||
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
|
||||
Future<void> _loadInitialState() async {
|
||||
await Future.wait([
|
||||
loadOverlayState(),
|
||||
loadTrailSettings(),
|
||||
loadRepeaterVisibilitySettings(),
|
||||
_loadCustomMapState(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_cadastral_overlay'),
|
||||
_showCadastralOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_forest_roads_overlay'),
|
||||
_showForestRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_hiking_trails_overlay'),
|
||||
_showHikingTrailsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_main_roads_overlay'),
|
||||
_showMainRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_house_numbers_overlay'),
|
||||
_showHouseNumbersOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_fire_hazard_zones_overlay'),
|
||||
_showFireHazardZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_historical_fires_overlay'),
|
||||
_showHistoricalFiresOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_firebreaks_overlay'),
|
||||
_showFirebreaksOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_kras_fire_zones_overlay'),
|
||||
_showKrasFireZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_place_names_overlay'),
|
||||
_showPlaceNamesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_municipality_borders_overlay'),
|
||||
_showMunicipalityBordersOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggle all contact trails on/off
|
||||
Future<void> toggleAllContactTrails() async {
|
||||
_showAllContactTrails = !_showAllContactTrails;
|
||||
notifyListeners();
|
||||
await _saveTrailSettings();
|
||||
}
|
||||
|
||||
/// Load trail settings from SharedPreferences
|
||||
Future<void> loadTrailSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
|
||||
_showAllContactTrails =
|
||||
prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Save trail settings to SharedPreferences
|
||||
Future<void> _saveTrailSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||
await prefs.setBool(
|
||||
_scopedKey('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(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||
}
|
||||
|
||||
Future<void> loadRepeaterVisibilitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_hideRepeatersOnMap =
|
||||
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set imported trail (from GPX import)
|
||||
void setImportedTrail(LocationTrail trail) {
|
||||
_importedTrail = trail;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear imported trail
|
||||
void clearImportedTrail() {
|
||||
_importedTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Replace current trail with imported trail
|
||||
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
||||
// End current trail if active
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
|
||||
// Set imported trail as current trail
|
||||
_currentTrail = importedTrail;
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Enter download area selection mode with initial bounds
|
||||
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
||||
_isSelectingDownloadArea = true;
|
||||
_downloadAreaBounds = initialBounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Exit download area selection mode
|
||||
void exitDownloadAreaMode() {
|
||||
_isSelectingDownloadArea = false;
|
||||
_downloadAreaBounds = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update the download area bounds (while dragging/resizing)
|
||||
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
||||
_downloadAreaBounds = bounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
|
||||
if (configJson != null && configJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(configJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final config = CustomMapConfig.fromJson(decoded);
|
||||
if (config != null && await File(config.filePath).exists()) {
|
||||
_customMapConfig = config;
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
_isUsingCustomMap =
|
||||
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
|
||||
_customMapConfig != null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_customMapConfig == null) {
|
||||
await prefs.remove(_scopedKey(_customMapConfigKey));
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_scopedKey(_customMapConfigKey),
|
||||
jsonEncode(_customMapConfig!.toJson()),
|
||||
);
|
||||
}
|
||||
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
|
||||
}
|
||||
|
||||
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
return (frame.image.width, frame.image.height);
|
||||
}
|
||||
|
||||
String _normalizedExtension(String displayName) {
|
||||
final dotIndex = displayName.lastIndexOf('.');
|
||||
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
|
||||
return 'png';
|
||||
}
|
||||
return displayName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> _deleteFileIfExists(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> exportWorkspaceJson() {
|
||||
return {
|
||||
'currentTrail': _currentTrail?.toJson(),
|
||||
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
|
||||
'importedTrail': _importedTrail?.toJson(),
|
||||
'isTrailVisible': _isTrailVisible,
|
||||
'showCadastralOverlay': _showCadastralOverlay,
|
||||
'showForestRoadsOverlay': _showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': _showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': _showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
|
||||
'showAllContactTrails': _showAllContactTrails,
|
||||
'hideRepeatersOnMap': _hideRepeatersOnMap,
|
||||
};
|
||||
}
|
||||
|
||||
void applyWorkspaceJson(Map<String, dynamic> json) {
|
||||
_currentTrail = json['currentTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_trailHistory
|
||||
..clear()
|
||||
..addAll(
|
||||
(json['trailHistory'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LocationTrail.fromJson),
|
||||
);
|
||||
_importedTrail = json['importedTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
|
||||
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
|
||||
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
json['showHikingTrailsOverlay'] as bool? ?? false;
|
||||
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
json['showHouseNumbersOverlay'] as bool? ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
json['showFireHazardZonesOverlay'] as bool? ?? false;
|
||||
_showHistoricalFiresOverlay =
|
||||
json['showHistoricalFiresOverlay'] as bool? ?? false;
|
||||
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
json['showKrasFireZonesOverlay'] as bool? ?? false;
|
||||
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
json['showMunicipalityBordersOverlay'] as bool? ?? false;
|
||||
_showAllContactTrails = json['showAllContactTrails'] as bool? ?? true;
|
||||
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||
import 'connection_provider.dart';
|
||||
import 'contacts_provider.dart';
|
||||
|
||||
@@ -15,6 +17,19 @@ class SensorsProvider with ChangeNotifier {
|
||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||
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>{
|
||||
'voltage',
|
||||
'battery',
|
||||
@@ -23,6 +38,14 @@ class SensorsProvider with ChangeNotifier {
|
||||
'pressure',
|
||||
'gps',
|
||||
};
|
||||
static const List<String> _defaultMetricOrder = <String>[
|
||||
'voltage',
|
||||
'battery',
|
||||
'temperature',
|
||||
'humidity',
|
||||
'pressure',
|
||||
'gps',
|
||||
];
|
||||
|
||||
final List<String> _watchedSensorKeys = <String>[];
|
||||
final Map<String, SensorRefreshState> _refreshStates =
|
||||
@@ -32,13 +55,22 @@ class SensorsProvider with ChangeNotifier {
|
||||
<String, Set<String>>{};
|
||||
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||
<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 _isRefreshingAll = false;
|
||||
bool _isRunningAutoRefreshTick = false;
|
||||
|
||||
SensorsProvider() {
|
||||
unawaited(_loadWatchedSensors());
|
||||
}
|
||||
|
||||
String _key(String baseKey) => ProfileStorageScope.scopedKey(baseKey);
|
||||
|
||||
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
|
||||
bool get isLoaded => _isLoaded;
|
||||
bool get isRefreshingAll => _isRefreshingAll;
|
||||
@@ -49,14 +81,26 @@ class SensorsProvider with ChangeNotifier {
|
||||
Future<void> _loadWatchedSensors() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
||||
final stored =
|
||||
prefs.getStringList(_key(_watchedSensorsKey)) ?? <String>[];
|
||||
final storedMetricsJson = prefs.getString(_key(_visibleSensorMetricsKey));
|
||||
final storedSpansJson = prefs.getString(_key(_fieldSpanKey));
|
||||
final storedLabelsJson = prefs.getString(_key(_metricLabelKey));
|
||||
final storedOrderJson = prefs.getString(_key(_metricOrderKey));
|
||||
final storedAutoRefreshJson = prefs.getString(
|
||||
_key(_autoRefreshMinutesKey),
|
||||
);
|
||||
_watchedSensorKeys
|
||||
..clear()
|
||||
..addAll(stored);
|
||||
_refreshStates.clear();
|
||||
_refreshStateUpdatedAt.clear();
|
||||
_visibleFieldsBySensor.clear();
|
||||
_fieldSpansBySensor.clear();
|
||||
_metricLabelsBySensor.clear();
|
||||
_metricOrderBySensor.clear();
|
||||
_autoRefreshMinutesBySensor.clear();
|
||||
_lastRefreshAttemptAt.clear();
|
||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
@@ -72,12 +116,49 @@ class SensorsProvider with ChangeNotifier {
|
||||
.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) {
|
||||
_visibleFieldsBySensor.putIfAbsent(
|
||||
key,
|
||||
() => Set<String>.from(_defaultVisibleFields),
|
||||
);
|
||||
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
|
||||
_metricOrderBySensor.putIfAbsent(
|
||||
key,
|
||||
() => List<String>.from(_defaultMetricOrder),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error loading watched sensors: $e');
|
||||
@@ -87,10 +168,15 @@ class SensorsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
_isLoaded = false;
|
||||
await _loadWatchedSensors();
|
||||
}
|
||||
|
||||
Future<void> _persistWatchedSensors() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
|
||||
await prefs.setStringList(_key(_watchedSensorsKey), _watchedSensorKeys);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving watched sensors: $e');
|
||||
}
|
||||
@@ -103,7 +189,10 @@ class SensorsProvider with ChangeNotifier {
|
||||
for (final entry in _visibleFieldsBySensor.entries) {
|
||||
encoded[entry.key] = entry.value.toList();
|
||||
}
|
||||
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
||||
await prefs.setString(
|
||||
_key(_visibleSensorMetricsKey),
|
||||
jsonEncode(encoded),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving visible sensor metrics: $e');
|
||||
}
|
||||
@@ -112,16 +201,71 @@ class SensorsProvider with ChangeNotifier {
|
||||
Future<void> _persistFieldSpans() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
|
||||
await prefs.setString(
|
||||
_key(_fieldSpanKey),
|
||||
jsonEncode(_fieldSpansBySensor),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving sensor field spans: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistMetricLabels() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_key(_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(
|
||||
_key(_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(
|
||||
_key(_autoRefreshMinutesKey),
|
||||
jsonEncode(_autoRefreshMinutesBySensor),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving sensor auto refresh minutes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||
_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) =>
|
||||
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||
|
||||
@@ -131,6 +275,104 @@ class SensorsProvider with ChangeNotifier {
|
||||
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(
|
||||
String publicKeyHex,
|
||||
String fieldKey,
|
||||
@@ -140,8 +382,17 @@ class SensorsProvider with ChangeNotifier {
|
||||
publicKeyHex,
|
||||
() => Set<String>.from(_defaultVisibleFields),
|
||||
);
|
||||
final metricOrder = _metricOrderBySensor.putIfAbsent(
|
||||
publicKeyHex,
|
||||
() => List<String>.from(_defaultMetricOrder),
|
||||
);
|
||||
var shouldPersistOrder = false;
|
||||
if (visible) {
|
||||
visibleFields.add(fieldKey);
|
||||
if (!metricOrder.contains(fieldKey)) {
|
||||
metricOrder.add(fieldKey);
|
||||
shouldPersistOrder = true;
|
||||
}
|
||||
} else {
|
||||
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||
return;
|
||||
@@ -149,6 +400,9 @@ class SensorsProvider with ChangeNotifier {
|
||||
visibleFields.remove(fieldKey);
|
||||
}
|
||||
await _persistVisibleMetrics();
|
||||
if (shouldPersistOrder) {
|
||||
await _persistMetricOrder();
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -166,11 +420,62 @@ class SensorsProvider with ChangeNotifier {
|
||||
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) =>
|
||||
_watchedSensorKeys.contains(publicKeyHex);
|
||||
|
||||
Future<void> addSensor(Contact contact) async {
|
||||
if (!contact.isChat && !contact.isRepeater) {
|
||||
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
|
||||
return;
|
||||
}
|
||||
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||
@@ -178,25 +483,69 @@ class SensorsProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
|
||||
await _persistWatchedSensors();
|
||||
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
||||
_defaultVisibleFields,
|
||||
);
|
||||
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
|
||||
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
|
||||
contact.publicKeyHex,
|
||||
initialVisibleFields,
|
||||
);
|
||||
await _persistVisibleMetrics();
|
||||
await _persistFieldSpans();
|
||||
await _persistMetricLabels();
|
||||
await _persistMetricOrder();
|
||||
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 {
|
||||
_watchedSensorKeys.remove(publicKeyHex);
|
||||
_refreshStates.remove(publicKeyHex);
|
||||
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||
_fieldSpansBySensor.remove(publicKeyHex);
|
||||
_metricLabelsBySensor.remove(publicKeyHex);
|
||||
_metricOrderBySensor.remove(publicKeyHex);
|
||||
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||
await _persistWatchedSensors();
|
||||
await _persistVisibleMetrics();
|
||||
await _persistFieldSpans();
|
||||
await _persistMetricLabels();
|
||||
await _persistMetricOrder();
|
||||
await _persistAutoRefreshMinutes();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -204,6 +553,7 @@ class SensorsProvider with ChangeNotifier {
|
||||
final candidates = <Contact>[
|
||||
...contactsProvider.chatContacts,
|
||||
...contactsProvider.repeaters,
|
||||
...contactsProvider.sensorContacts,
|
||||
];
|
||||
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||
@@ -239,7 +589,14 @@ class SensorsProvider with ChangeNotifier {
|
||||
required String publicKeyHex,
|
||||
required ContactsProvider contactsProvider,
|
||||
required ConnectionProvider connectionProvider,
|
||||
DateTime? requestedAt,
|
||||
}) async {
|
||||
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
|
||||
|
||||
Contact? contact;
|
||||
for (final entry in contactsProvider.contacts) {
|
||||
if (entry.publicKeyHex == publicKeyHex) {
|
||||
@@ -266,6 +623,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}) {
|
||||
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||
final keysToClear = <String>[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
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');
|
||||
_playingSessionId = sessionId;
|
||||
notifyListeners();
|
||||
await _player.play(pcm);
|
||||
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||
if (_playingSessionId == sessionId) {
|
||||
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
|
||||
387
lib/screens/add_contact_screen.dart
Normal file
387
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,387 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../l10n/app_localizations.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(SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)));
|
||||
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(SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)));
|
||||
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: Text(AppLocalizations.of(context)!.addContact)),
|
||||
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: [
|
||||
_ImportHintChip(
|
||||
icon: Icons.link_outlined,
|
||||
label: AppLocalizations.of(context)!.acceptsShareLinks,
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.code_outlined,
|
||||
label: AppLocalizations.of(context)!.supportsRawHex,
|
||||
),
|
||||
_ImportHintChip(
|
||||
icon: Icons.content_paste_go_outlined,
|
||||
label: AppLocalizations.of(context)!.clipboardfriendly,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: Icon(Icons.content_paste_go_outlined),
|
||||
label: Text(AppLocalizations.of(context)!.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'
|
||||
: 'Add 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
732
lib/screens/discovery_screen.dart
Normal file
732
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,732 @@
|
||||
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: Text(AppLocalizations.of(context)!.clearDiscoveries),
|
||||
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: Text(AppLocalizations.of(context)!.clearAllLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.read<ContactsProvider>().clearPendingAdverts();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
for (final advert in adverts) {
|
||||
if (!mounted) break;
|
||||
// Skip already-resolved contacts
|
||||
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||
continue;
|
||||
}
|
||||
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: Text(l10n.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),
|
||||
SizedBox(width: 12),
|
||||
Text(l10n.discoverRepeaters),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
SizedBox(width: 12),
|
||||
Text(l10n.discoverSensors),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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>[];
|
||||
|
||||
// Track which pending adverts are already in the contacts list
|
||||
final resolvedKeySet = <String>{};
|
||||
for (final advert in pendingAdverts) {
|
||||
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||
resolvedKeySet.add(advert.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
final totalCount = pendingAdverts.length;
|
||||
|
||||
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(
|
||||
'Discovered nodes ($totalCount)',
|
||||
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: Text(l10n.resolveAll),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: Icon(Icons.clear_all_rounded),
|
||||
label: Text(l10n.clearAllLabel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (totalCount == 0)
|
||||
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 discovered nodes',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Use the menu to discover repeaters and sensors on the mesh.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) =>
|
||||
_buildPendingAdvertCard(
|
||||
context,
|
||||
advert: advert,
|
||||
contactsProvider: contactsProvider,
|
||||
isConnected: isConnected,
|
||||
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
|
||||
cachedNodes: cachedNodes,
|
||||
l10n: l10n,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPendingAdvertCard(
|
||||
BuildContext context, {
|
||||
required PendingAdvert advert,
|
||||
required ContactsProvider contactsProvider,
|
||||
required bool isConnected,
|
||||
required bool isResolved,
|
||||
required List<MeshMapNode> cachedNodes,
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
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),
|
||||
if (isResolved)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 24,
|
||||
)
|
||||
else if (isResolving)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -6,23 +8,34 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:vibration/vibration.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import '../models/device_info.dart' show ConnectionMode;
|
||||
import '../models/contact.dart';
|
||||
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
|
||||
import '../providers/messages_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'messages_tab.dart';
|
||||
import 'contacts_tab.dart';
|
||||
import 'discovery_screen.dart';
|
||||
import 'sensors_tab.dart';
|
||||
import 'map_tab.dart';
|
||||
import 'repeaters_map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'device_config_screen.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
import 'live_traffic_screen.dart';
|
||||
import 'profiles_screen.dart';
|
||||
import 'spectrum_scan_screen.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/permission_request_dialog.dart';
|
||||
import '../widgets/connection_dialog.dart';
|
||||
import '../utils/battery_display_helper.dart';
|
||||
import '../services/developer_mode_service.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/profile_device_key_resolver.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
|
||||
enum _HomeTab { messages, contacts, sensors, map }
|
||||
|
||||
@@ -52,13 +65,16 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
late TabController _tabController;
|
||||
late final AppProvider _appProvider;
|
||||
late final ConnectionProvider _connectionProvider;
|
||||
int _currentIndex = 0;
|
||||
bool _isMapFullscreen = false;
|
||||
bool _showRxTxIndicators = true;
|
||||
bool _isDeveloperModeEnabled = false;
|
||||
bool _isMapEnabled = true;
|
||||
bool _isContactsEnabled = true;
|
||||
bool _isSensorsEnabled = false;
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
String? _lastProfileDeviceKey;
|
||||
|
||||
List<_HomeTab> get _enabledTabs {
|
||||
return [
|
||||
@@ -82,6 +98,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_appProvider = context.read<AppProvider>();
|
||||
_connectionProvider = context.read<ConnectionProvider>();
|
||||
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||
_isMapEnabled = _appProvider.isMapEnabled;
|
||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||
@@ -90,6 +108,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
// Initialize synchronously so first build always has a valid controller.
|
||||
_initTabController();
|
||||
_loadRxTxPreference();
|
||||
_loadDeveloperModePreference();
|
||||
MeshMapNodesService.syncInBackgroundIfStale();
|
||||
|
||||
// Show permission dialog after the first frame if needed
|
||||
if (widget.shouldShowPermissionDialog) {
|
||||
@@ -97,6 +117,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_showPermissionDialog();
|
||||
});
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleConnectionProviderChanged();
|
||||
});
|
||||
}
|
||||
|
||||
void _initTabController() {
|
||||
@@ -192,7 +216,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
switch (tab) {
|
||||
case _HomeTab.messages:
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
break;
|
||||
case _HomeTab.contacts:
|
||||
context.read<ContactsProvider>().markAllAsViewed();
|
||||
@@ -213,23 +236,28 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
_syncFastLocationUiState();
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
|
||||
});
|
||||
void _handleConnectionProviderChanged() {
|
||||
final deviceKey = ProfileDeviceKeyResolver.resolve(
|
||||
deviceInfo: _connectionProvider.deviceInfo,
|
||||
connectionMode: _connectionProvider.connectionMode,
|
||||
);
|
||||
if (_lastProfileDeviceKey == deviceKey) {
|
||||
return;
|
||||
}
|
||||
_lastProfileDeviceKey = deviceKey;
|
||||
if (deviceKey == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.syncActiveProfileForCurrentDevice(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_appProvider.setFastLocationUiActive(false);
|
||||
_appProvider.removeListener(_handleAppProviderChanged);
|
||||
@@ -238,6 +266,40 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openLiveTraffic(ConnectionProvider provider) {
|
||||
openLiveTrafficScreen(context, provider);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
_syncFastLocationUiState();
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
MeshMapNodesService.syncInBackgroundIfStale();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_showRxTxIndicators =
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDeveloperModePreference() async {
|
||||
final isEnabled = await DeveloperModeService.isEnabled();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isDeveloperModeEnabled = isEnabled;
|
||||
});
|
||||
}
|
||||
|
||||
void _showPermissionDialog() {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -333,7 +395,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
),
|
||||
title: Text(l10n.flood),
|
||||
subtitle: const Text('Relay through repeaters across the mesh'),
|
||||
subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -359,7 +421,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
),
|
||||
title: Text(l10n.direct),
|
||||
subtitle: const Text('Nearby only, without repeater flooding'),
|
||||
subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -373,34 +435,160 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivityBadge({
|
||||
required String label,
|
||||
required int count,
|
||||
required bool isActive,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final color = isActive ? activeColor : Colors.grey;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
|
||||
// Request self telemetry so it's fresh
|
||||
context.read<ConnectionProvider>().requestSelfTelemetry();
|
||||
|
||||
// Find the device's own contact to show self telemetry
|
||||
final selfKey = deviceInfo.publicKey;
|
||||
Contact? selfContact;
|
||||
if (selfKey != null) {
|
||||
selfContact = context.read<ContactsProvider>().findContactByKey(
|
||||
Uint8List.fromList(selfKey),
|
||||
);
|
||||
}
|
||||
final telemetry = selfContact?.telemetry;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.bluetooth,
|
||||
'BLE Signal',
|
||||
deviceInfo.signalRssi != null
|
||||
? '${deviceInfo.signalRssi} dBm'
|
||||
: 'N/A',
|
||||
),
|
||||
if (deviceInfo.batteryPercent != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
BatteryDisplayHelper.getBatteryIcon(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
'Battery',
|
||||
'${deviceInfo.batteryPercent!.round()}%',
|
||||
),
|
||||
if (deviceInfo.batteryMilliVolts != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.bolt,
|
||||
'Voltage',
|
||||
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
|
||||
),
|
||||
if (deviceInfo.storageUsedKb != null &&
|
||||
deviceInfo.storageTotalKb != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.storage,
|
||||
'Storage',
|
||||
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
|
||||
),
|
||||
if (deviceInfo.firmwareVersion != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.system_update,
|
||||
'Firmware',
|
||||
'v${deviceInfo.firmwareVersion}',
|
||||
),
|
||||
if (deviceInfo.radioFreq != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.radio,
|
||||
'Frequency',
|
||||
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
|
||||
),
|
||||
if (deviceInfo.txPower != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.power,
|
||||
'TX Power',
|
||||
'${deviceInfo.txPower} dBm',
|
||||
),
|
||||
if (telemetry != null) ...[
|
||||
const Divider(height: 24),
|
||||
Text(
|
||||
'Self Telemetry',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (telemetry.temperature != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.thermostat,
|
||||
'Temperature',
|
||||
'${telemetry.temperature!.toStringAsFixed(1)}°C',
|
||||
),
|
||||
if (telemetry.humidity != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.water_drop,
|
||||
'Humidity',
|
||||
'${telemetry.humidity!.toStringAsFixed(1)}%',
|
||||
),
|
||||
if (telemetry.pressure != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.compress,
|
||||
'Pressure',
|
||||
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
|
||||
),
|
||||
if (telemetry.gpsLocation != null)
|
||||
_deviceInfoRow(
|
||||
context,
|
||||
Icons.gps_fixed,
|
||||
'GPS',
|
||||
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _deviceInfoRow(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
String value,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$label:$count',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -408,6 +596,27 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
||||
final inactive = Colors.grey.withValues(alpha: 0.3);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < 5; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 1.5),
|
||||
Container(
|
||||
width: 2.5,
|
||||
height: 4.0 + (i * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: i < activeBars ? color : inactive,
|
||||
borderRadius: BorderRadius.circular(1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactActivityIndicator({
|
||||
required bool rxActive,
|
||||
required bool txActive,
|
||||
@@ -564,6 +773,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
context.watch<AppProvider>();
|
||||
final activeProfileId = context.select<ProfileManager, String>(
|
||||
(manager) => manager.activeProfileId,
|
||||
);
|
||||
|
||||
final enabledTabs = _enabledTabs;
|
||||
final isMapTabActive = _currentTab == _HomeTab.map;
|
||||
@@ -588,7 +800,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
onPressed: () async {
|
||||
await provider.disconnect();
|
||||
},
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
icon: Icon(Icons.power_settings_new),
|
||||
tooltip: AppLocalizations.of(context)!.disconnect,
|
||||
color: Colors.red.shade700,
|
||||
);
|
||||
@@ -598,56 +810,188 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.radar),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Spectrum Scan'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context);
|
||||
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: () {
|
||||
// Capture context-dependent objects before async gap
|
||||
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,
|
||||
itemBuilder: (context) {
|
||||
final items = <PopupMenuEntry<void>>[];
|
||||
final profilesEnabled = context
|
||||
.read<ProfileManager>()
|
||||
.profilesEnabled;
|
||||
|
||||
if (_isDeveloperModeEnabled) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.radar),
|
||||
SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.spectrumScan),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context);
|
||||
Future.delayed(Duration.zero, () {
|
||||
if (!mounted) return;
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
const SpectrumScanScreen(),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.radar_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.liveTraffic),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context);
|
||||
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: Row(
|
||||
children: [
|
||||
const Icon(Icons.router_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.repeatersMap),
|
||||
],
|
||||
),
|
||||
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),
|
||||
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();
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (profilesEnabled) {
|
||||
items.add(
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.layers_outlined),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.profiles),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context);
|
||||
Future.delayed(Duration.zero, () {
|
||||
if (!mounted) return;
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ProfilesScreen(),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -657,6 +1001,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
switch (tab) {
|
||||
case _HomeTab.messages:
|
||||
return MessagesTab(
|
||||
isActive:
|
||||
_currentTab == _HomeTab.messages &&
|
||||
_lifecycleState == AppLifecycleState.resumed,
|
||||
onNavigateToMap: _isMapEnabled
|
||||
? () => _navigateToTab(_HomeTab.map)
|
||||
: null,
|
||||
@@ -666,11 +1013,17 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
onNavigateToMap: _isMapEnabled
|
||||
? () => _navigateToTab(_HomeTab.map)
|
||||
: null,
|
||||
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||
);
|
||||
case _HomeTab.sensors:
|
||||
return const SensorsTab();
|
||||
return SensorsTab(
|
||||
isActive:
|
||||
_currentTab == _HomeTab.sensors &&
|
||||
_lifecycleState == AppLifecycleState.resumed,
|
||||
);
|
||||
case _HomeTab.map:
|
||||
return MapTab(
|
||||
key: ValueKey<String>('map:$activeProfileId'),
|
||||
onFullscreenChanged: (isFullscreen) {
|
||||
setState(() {
|
||||
_isMapFullscreen = isFullscreen;
|
||||
@@ -715,7 +1068,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
Icons.message,
|
||||
unreadCount,
|
||||
),
|
||||
text: AppLocalizations.of(context)!.messages,
|
||||
text: 'Chat',
|
||||
);
|
||||
case _HomeTab.contacts:
|
||||
return Tab(
|
||||
@@ -727,7 +1080,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
case _HomeTab.map:
|
||||
return Tab(
|
||||
icon: const Icon(Icons.map),
|
||||
icon: Icon(Icons.map),
|
||||
text: AppLocalizations.of(context)!.map,
|
||||
);
|
||||
case _HomeTab.sensors:
|
||||
@@ -752,19 +1105,35 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||
|
||||
if (!isConnected) {
|
||||
// Disconnected state: show connect button
|
||||
final buttonLabel = provider.isReconnecting
|
||||
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
||||
: AppLocalizations.of(context)!.connect;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.appTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
provider.isReconnecting
|
||||
? 'Restoring previous link'
|
||||
: 'No device connected',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
FilledButton.icon(
|
||||
onPressed: provider.isReconnecting
|
||||
? null
|
||||
: () => _showConnectionDialog(context),
|
||||
@@ -775,22 +1144,19 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.black54,
|
||||
Colors.white70,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.bluetooth, size: 18),
|
||||
label: Text(
|
||||
provider.isReconnecting
|
||||
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
||||
: AppLocalizations.of(context)!.connect,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black87,
|
||||
elevation: 0,
|
||||
: const Icon(Icons.add_link_rounded, size: 18),
|
||||
label: Text(buttonLabel),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -798,7 +1164,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
onPressed: () => provider.cancelReconnection(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
icon: Icon(Icons.close, size: 20),
|
||||
tooltip: AppLocalizations.of(context)!.cancelReconnection,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.red.shade700,
|
||||
@@ -857,7 +1223,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
GestureDetector(
|
||||
onTap: () => _showDeviceInfoSheet(
|
||||
context,
|
||||
deviceInfo,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
@@ -870,17 +1241,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (!isTcpConnected &&
|
||||
deviceInfo.signalRssi != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(
|
||||
'${deviceInfo.signalRssi}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: signalColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
_buildMiniSignalBars(
|
||||
activeBars:
|
||||
BatteryDisplayHelper.getSignalBars(
|
||||
deviceInfo.signalRssi!,
|
||||
),
|
||||
color: signalColor,
|
||||
),
|
||||
],
|
||||
if (deviceInfo.batteryPercent != null) ...[
|
||||
@@ -895,25 +1261,22 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: 30,
|
||||
child: Text(
|
||||
'${deviceInfo.batteryPercent!.round()}%',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
BatteryDisplayHelper.getBatteryColor(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${deviceInfo.batteryPercent!.round()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color:
|
||||
BatteryDisplayHelper.getBatteryColor(
|
||||
deviceInfo.batteryPercent!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -999,6 +1362,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
SizedBox(width: isTight ? 8 : 12),
|
||||
if (_showRxTxIndicators)
|
||||
GestureDetector(
|
||||
onTap: () => _openLiveTraffic(provider),
|
||||
onLongPress: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -1008,44 +1372,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
child: isTight
|
||||
? _buildCompactActivityIndicator(
|
||||
rxActive: provider.rxActivity,
|
||||
txActive: provider.txActivity,
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh
|
||||
.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_buildActivityBadge(
|
||||
label: 'RX',
|
||||
count: provider.rxPacketCount,
|
||||
isActive: provider.rxActivity,
|
||||
activeColor: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildActivityBadge(
|
||||
label: 'TX',
|
||||
count: provider.txPacketCount,
|
||||
isActive: provider.txActivity,
|
||||
activeColor: Colors.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _buildCompactActivityIndicator(
|
||||
rxActive: provider.rxActivity,
|
||||
txActive: provider.txActivity,
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(width: isTight ? 24 : 74),
|
||||
const SizedBox(width: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
1154
lib/screens/live_traffic_screen.dart
Normal file
1154
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ import '../services/voice_recorder_service.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import '../utils/contact_sorting.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../utils/tictactoe_message_parser.dart';
|
||||
@@ -39,8 +40,9 @@ import '../l10n/app_localizations.dart';
|
||||
|
||||
class MessagesTab extends StatefulWidget {
|
||||
final VoidCallback? onNavigateToMap;
|
||||
final bool isActive;
|
||||
|
||||
const MessagesTab({super.key, this.onNavigateToMap});
|
||||
const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
|
||||
|
||||
@override
|
||||
State<MessagesTab> createState() => _MessagesTabState();
|
||||
@@ -50,6 +52,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxContactMessageBytes = 156;
|
||||
static const int _maxChannelMessageBytes = 127;
|
||||
static const double _composerOverlayHeight = 148;
|
||||
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
|
||||
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
@@ -57,6 +60,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
int _messageByteCount = 0;
|
||||
String? _highlightedMessageId;
|
||||
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
|
||||
String _destinationType =
|
||||
@@ -71,7 +80,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||
bool _isRecording = 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 _silencePeakThreshold = 1400.0;
|
||||
static const int _maxInteriorSilentChunks = 2;
|
||||
@@ -85,18 +94,17 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textController.addListener(_updateCharacterCount);
|
||||
_textController.addListener(_handleComposerChanged);
|
||||
_focusNode.addListener(_handleFocusChanged);
|
||||
// Load saved message destination
|
||||
_loadSavedDestination();
|
||||
_loadVoiceBitrate();
|
||||
// Mark all messages as read when tab is opened
|
||||
_loadVoiceSettings();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
_checkForNavigationRequest();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadVoiceBitrate() async {
|
||||
Future<void> _loadVoiceSettings() async {
|
||||
final bitrate = await VoiceBitratePreferences.getBitrate();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -117,29 +125,52 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
@override
|
||||
void dispose() {
|
||||
_highlightTimer?.cancel();
|
||||
_channelReadTimer?.cancel();
|
||||
_voiceStreamSub?.cancel();
|
||||
_voiceRecorder.dispose();
|
||||
_focusNode.removeListener(_handleFocusChanged);
|
||||
_textController.dispose();
|
||||
_focusNode.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MessagesTab oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isActive != widget.isActive) {
|
||||
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
|
||||
}
|
||||
}
|
||||
|
||||
void _checkForNavigationRequest() {
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final targetMessageId = messagesProvider.targetMessageId;
|
||||
final targetDestinationType = messagesProvider.targetDestinationType;
|
||||
|
||||
if (targetMessageId != null) {
|
||||
_scrollToMessage(targetMessageId);
|
||||
messagesProvider.clearMessageNavigation();
|
||||
}
|
||||
|
||||
if (targetDestinationType != null) {
|
||||
_applyPendingDestination(
|
||||
type: targetDestinationType,
|
||||
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
|
||||
);
|
||||
messagesProvider.clearDestinationNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToMessage(String messageId) {
|
||||
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) {
|
||||
// Calculate position - accounting for reverse list
|
||||
@@ -170,12 +201,130 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleComposerChanged() {
|
||||
_updateCharacterCount();
|
||||
|
||||
if (_suppressMentionTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
_updateMentionSuggestions(_textController.value);
|
||||
}
|
||||
|
||||
void _updateCharacterCount() {
|
||||
setState(() {
|
||||
_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.chatContacts.where((contact) {
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
return contact.displayName.toLowerCase().contains(normalizedQuery);
|
||||
}).toList()..sort((a, b) {
|
||||
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||
if (primary != 0) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
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 compareContactsByDisplayName(a, b);
|
||||
});
|
||||
|
||||
return contacts.take(8).toList(growable: false);
|
||||
}
|
||||
|
||||
int get _maxMessageBytes =>
|
||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||
? _maxChannelMessageBytes
|
||||
@@ -258,17 +407,35 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
/// Show recipient selector bottom sheet
|
||||
void _showRecipientSelector() {
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
|
||||
// Filter contacts by type
|
||||
final contacts = contactsProvider.contacts
|
||||
.where((c) => c.type == ContactType.chat)
|
||||
.toList();
|
||||
final rooms = contactsProvider.contacts
|
||||
.where((c) => c.type == ContactType.room)
|
||||
.toList();
|
||||
final channels = contactsProvider.contacts
|
||||
.where((c) => c.type == ContactType.channel)
|
||||
.toList();
|
||||
final contacts = List<Contact>.from(contactsProvider.chatContacts)
|
||||
..sort((a, b) {
|
||||
final primary = compareContactsByFavouriteThenLastSeen(a, b);
|
||||
if (primary != 0) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
return compareContactsByDisplayName(a, b);
|
||||
});
|
||||
final rooms = List<Contact>.from(contactsProvider.rooms)
|
||||
..sort((a, b) {
|
||||
final primary = compareContactsByLastSeen(a, b);
|
||||
if (primary != 0) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
return compareContactsByDisplayName(a, b);
|
||||
});
|
||||
final channels = List<Contact>.from(contactsProvider.channels)
|
||||
..sort((a, b) {
|
||||
final primary = compareContactsByLastSeen(a, b);
|
||||
if (primary != 0) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
return compareContactsByDisplayName(a, b);
|
||||
});
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -278,6 +445,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
contacts: contacts,
|
||||
rooms: rooms,
|
||||
channels: channels,
|
||||
unreadCount: messagesProvider.unreadCount,
|
||||
unreadCountsByPublicKey: {
|
||||
for (final contact in [...contacts, ...rooms, ...channels])
|
||||
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
|
||||
contact,
|
||||
),
|
||||
},
|
||||
currentDestinationType: _destinationType,
|
||||
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
||||
onSelect: _onRecipientSelected,
|
||||
@@ -292,6 +466,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_selectedRecipient = recipient;
|
||||
});
|
||||
|
||||
_markCurrentDestinationAsRead();
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
|
||||
// Save to preferences
|
||||
@@ -304,9 +480,147 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
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 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,
|
||||
AppLocalizations.of(context)!.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,
|
||||
AppLocalizations.of(context)!.cannotReplySenderMissing,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||
if (recipient == null) {
|
||||
ToastLogger.error(
|
||||
context,
|
||||
AppLocalizations.of(context)!.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
|
||||
IconData _getDestinationIcon() {
|
||||
if (_destinationType ==
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||
return Icons.all_inbox;
|
||||
} else if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
return Icons.public;
|
||||
} else if (_destinationType ==
|
||||
@@ -318,6 +632,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
|
||||
String _getDestinationLabel() {
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||
return AppLocalizations.of(context)!.showAll;
|
||||
}
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
_selectedRecipient != null) {
|
||||
@@ -343,6 +660,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||
if (!mounted) return;
|
||||
ToastLogger.error(context, 'Select a channel, contact, or room first');
|
||||
return;
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
|
||||
try {
|
||||
// Check destination type and send accordingly
|
||||
if (_destinationType ==
|
||||
@@ -373,12 +699,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
await _sendToChannel(text, connectionProvider, messagesProvider, 0);
|
||||
}
|
||||
|
||||
_textController.clear();
|
||||
_focusNode.unfocus();
|
||||
_markCurrentDestinationAsRead();
|
||||
|
||||
if (!mounted) return;
|
||||
} catch (e) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -692,7 +1024,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
Future<void> _startVoiceRecording() async {
|
||||
if (_isSendingVoice || _isRecording) return;
|
||||
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();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -731,11 +1063,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_selectedVoiceBitrate,
|
||||
);
|
||||
final packetDuration = Duration(
|
||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
||||
milliseconds: _activeVoiceMode!.packetDurationMs,
|
||||
);
|
||||
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
|
||||
);
|
||||
|
||||
_recordedChunks.clear();
|
||||
@@ -744,9 +1077,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
try {
|
||||
final stream = _voiceRecorder.startCapture(
|
||||
chunkDuration: packetDuration,
|
||||
sampleRateHz: _activeVoiceMode!.sampleRateHz,
|
||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
|
||||
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
|
||||
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
);
|
||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||
_voiceStreamSub = stream.listen(
|
||||
@@ -757,7 +1094,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||
);
|
||||
setState(() {});
|
||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
||||
if (_recordedChunks.length >= maxVoicePackets) {
|
||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||
_stopAndSendVoice();
|
||||
}
|
||||
@@ -774,6 +1111,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 {
|
||||
if (!_isRecording) return;
|
||||
final trimSilenceEnabled = context
|
||||
@@ -788,9 +1131,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
await _voiceRecorder.stopCapture();
|
||||
|
||||
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
||||
final trimmedChunks = trimSilenceEnabled
|
||||
? _trimSilence(rawChunks)
|
||||
: rawChunks;
|
||||
final sessionId = _currentVoiceSessionId;
|
||||
final mode = _activeVoiceMode;
|
||||
final chunks = mode == null
|
||||
? trimmedChunks
|
||||
: _prepareChunksForSending(trimmedChunks, mode);
|
||||
_recordedChunks.clear();
|
||||
|
||||
debugPrint(
|
||||
@@ -979,6 +1327,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||
}
|
||||
|
||||
List<Int16List> _prepareChunksForSending(
|
||||
List<Int16List> chunks,
|
||||
VoicePacketMode mode,
|
||||
) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||
if (chunks.isEmpty) return chunks;
|
||||
|
||||
@@ -1008,16 +1363,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
bool _isSilentChunk(Int16List chunk) {
|
||||
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;
|
||||
for (final sample in chunk) {
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
return math.sqrt(sumSquares / chunk.length);
|
||||
}
|
||||
|
||||
int _chunkPeak(Int16List chunk) {
|
||||
var peak = 0;
|
||||
for (final sample in chunk) {
|
||||
final absSample = sample.abs();
|
||||
if (absSample > peak) peak = absSample;
|
||||
sumSquares += sample * sample;
|
||||
if (absSample > peak) {
|
||||
peak = absSample;
|
||||
}
|
||||
}
|
||||
|
||||
final rms = math.sqrt(sumSquares / chunk.length);
|
||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||
return peak;
|
||||
}
|
||||
|
||||
bool _isPublicChannelSelected() {
|
||||
@@ -1035,7 +1402,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
final decision = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Send to Public Channel?'),
|
||||
title: Text(AppLocalizations.of(context)!.sendToPublicChannel),
|
||||
content: Text(
|
||||
'You are about to send $mediaType to the Public Channel. '
|
||||
'This is not advised because everyone on the mesh may receive it. '
|
||||
@@ -1048,7 +1415,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Send anyway'),
|
||||
child: Text(AppLocalizations.of(context)!.sendAnyway),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1108,7 +1475,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_location_alt),
|
||||
leading: Icon(Icons.search),
|
||||
title: Text(AppLocalizations.of(context)!.searchMessages),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
_showFilteredMessageSearch();
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.add_location_alt),
|
||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
@@ -1135,8 +1511,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('Send image from gallery'),
|
||||
leading: Icon(Icons.photo_library),
|
||||
title: Text(AppLocalizations.of(context)!.sendImageFromGallery),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () async {
|
||||
@@ -1147,8 +1523,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('Take photo'),
|
||||
leading: Icon(Icons.camera_alt),
|
||||
title: Text(AppLocalizations.of(context)!.takePhoto),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () async {
|
||||
@@ -1158,9 +1534,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.grid_3x3),
|
||||
title: const Text('Start Tic-Tac-Toe'),
|
||||
subtitle: const Text('DM only'),
|
||||
leading: Icon(Icons.grid_3x3),
|
||||
title: Text(AppLocalizations.of(context)!.startTictactoe),
|
||||
subtitle: Text(AppLocalizations.of(context)!.dmOnly),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _startTicTacToeGame();
|
||||
@@ -1174,6 +1550,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) {
|
||||
final recipient = _selectedRecipient;
|
||||
if (recipient != null) {
|
||||
@@ -1432,6 +1918,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) {
|
||||
// Get all recent messages
|
||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||
@@ -1439,17 +1984,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
List<Message> filteredMessages;
|
||||
|
||||
// If channel destination is selected, filter by selected channel.
|
||||
if (_destinationType ==
|
||||
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||
filteredMessages = allMessages;
|
||||
} else if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel) {
|
||||
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||
if (selectedChannelIdx == 0) {
|
||||
// Public channel view keeps showing all messages (current app behavior).
|
||||
filteredMessages = allMessages;
|
||||
} else {
|
||||
filteredMessages = allMessages
|
||||
.where((message) => message.channelIdx == selectedChannelIdx)
|
||||
.toList();
|
||||
}
|
||||
filteredMessages = allMessages
|
||||
.where(
|
||||
(message) =>
|
||||
message.isChannelMessage &&
|
||||
(message.channelIdx ?? 0) == selectedChannelIdx,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
// If a contact or room is selected, filter by recipient/sender prefixes.
|
||||
else if ((_destinationType ==
|
||||
@@ -1458,6 +2004,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
MessageDestinationPreferences.destinationTypeRoom) &&
|
||||
_selectedRecipient != null) {
|
||||
filteredMessages = allMessages.where((message) {
|
||||
if (!message.isContactMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include messages sent TO this recipient
|
||||
if (message.recipientPublicKey != null &&
|
||||
message.recipientPublicKey!.length >= 6 &&
|
||||
@@ -1496,12 +2046,19 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
void _handleMessageTap(Message message) {
|
||||
if (widget.onNavigateToMap == null) return;
|
||||
|
||||
if (message.isSarMarker && message.sarGpsCoordinates != null) {
|
||||
if (message.isSarMarker) {
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
mapProvider.navigateToLocation(
|
||||
location: message.sarGpsCoordinates!,
|
||||
zoom: 15.0,
|
||||
);
|
||||
final marker = message.toSarMarker();
|
||||
if (marker == null) {
|
||||
return;
|
||||
}
|
||||
final error = mapProvider.navigateToSarMarker(marker);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
return;
|
||||
}
|
||||
widget.onNavigateToMap?.call();
|
||||
return;
|
||||
}
|
||||
@@ -1510,7 +2067,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
|
||||
final mapProvider = context.read<MapProvider>();
|
||||
final drawingProvider = context.read<DrawingProvider>();
|
||||
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
|
||||
final error = mapProvider.navigateToDrawing(
|
||||
message.drawingId!,
|
||||
drawingProvider,
|
||||
);
|
||||
if (error != null && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
return;
|
||||
}
|
||||
widget.onNavigateToMap?.call();
|
||||
}
|
||||
}
|
||||
@@ -1519,7 +2085,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MessagesProvider>(
|
||||
builder: (context, messagesProvider, child) {
|
||||
final messages = _getFilteredMessages(messagesProvider);
|
||||
_syncChannelAutoReadTimer(messagesProvider);
|
||||
final messages = messagesProvider.buildDisplayMessages(
|
||||
_getFilteredMessages(messagesProvider),
|
||||
);
|
||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||
|
||||
@@ -1538,6 +2107,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
onRefresh: _handleRefresh,
|
||||
onNavigateToMap: widget.onNavigateToMap,
|
||||
onMessageTap: _handleMessageTap,
|
||||
onReplyToMessage: _replyToMessage,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -1556,6 +2126,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
bottomPadding: composerBottomPadding,
|
||||
destinationLabel: _getDestinationLabel(),
|
||||
destinationAvatar: _buildDestinationAvatar(context),
|
||||
mentionSuggestions: _mentionSuggestions,
|
||||
mentionQuery: _mentionQuery,
|
||||
onMentionSelected: _selectMention,
|
||||
onShowComposerActions: _showComposerActions,
|
||||
onShowRecipientSelector: _showRecipientSelector,
|
||||
onStartVoiceRecording: _startVoiceRecording,
|
||||
|
||||
@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_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';
|
||||
|
||||
class PacketLogScreen extends StatefulWidget {
|
||||
@@ -59,7 +61,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -106,7 +108,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
||||
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -153,8 +155,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
void _copyToClipboard(BuildContext context, BlePacketLog log) {
|
||||
Clipboard.setData(ClipboardData(text: log.hexData));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Hex data copied to clipboard'),
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.hexDataCopiedToClipboard),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
@@ -182,7 +184,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
setState(() {});
|
||||
if (parentContext.mounted) {
|
||||
ScaffoldMessenger.of(parentContext).showSnackBar(
|
||||
const SnackBar(content: Text('Packet logs cleared')),
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.packetLogsCleared)),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -203,7 +205,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('BLE Packet Logs'),
|
||||
Text(AppLocalizations.of(context)!.blePacketLogs),
|
||||
Text(
|
||||
'${logs.length} packets',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
@@ -314,23 +316,23 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
icon: const Icon(Icons.share),
|
||||
tooltip: 'Export logs',
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'csv',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.table_chart),
|
||||
SizedBox(width: 8),
|
||||
Text('Export as CSV'),
|
||||
const Icon(Icons.table_chart),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.exportAsCsv),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
PopupMenuItem(
|
||||
value: 'txt',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.text_snippet),
|
||||
SizedBox(width: 8),
|
||||
Text('Export as Text'),
|
||||
const Icon(Icons.text_snippet),
|
||||
const SizedBox(width: 8),
|
||||
Text(AppLocalizations.of(context)!.exportAsText),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -409,8 +411,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
||||
_filterDirection = null;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Clear filters'),
|
||||
icon: Icon(Icons.clear_all),
|
||||
label: Text(AppLocalizations.of(context)!.clearFilters),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
|
||||
final isRx = log.direction == PacketDirection.rx;
|
||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||
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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
@@ -537,24 +519,24 @@ class _PacketLogCard extends StatelessWidget {
|
||||
children: [
|
||||
_FactCard(
|
||||
icon: isRx ? Icons.call_received : Icons.call_made,
|
||||
label: 'Direction',
|
||||
label: AppLocalizations.of(context)!.direction,
|
||||
value: isRx ? 'RX' : 'TX',
|
||||
accent: directionColor,
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.data_object,
|
||||
label: 'Size',
|
||||
label: AppLocalizations.of(context)!.size,
|
||||
value: '${log.rawData.length} bytes',
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.schedule,
|
||||
label: 'Captured',
|
||||
label: AppLocalizations.of(context)!.captured,
|
||||
value: _formatTimestamp(log.timestamp),
|
||||
),
|
||||
if (log.responseCode != null)
|
||||
_FactCard(
|
||||
icon: Icons.sell,
|
||||
label: 'Opcode',
|
||||
label: AppLocalizations.of(context)!.opcode,
|
||||
value: log.opcodeName,
|
||||
),
|
||||
],
|
||||
@@ -583,7 +565,7 @@ class _PacketLogCard extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
if (rxInfo?.rssiDbm != null)
|
||||
_SignalMeter(
|
||||
label: 'RSSI',
|
||||
label: AppLocalizations.of(context)!.rssi,
|
||||
valueLabel: '${rxInfo!.rssiDbm} dBm',
|
||||
normalized: _normalizeRssi(
|
||||
rxInfo.rssiDbm!.toDouble(),
|
||||
@@ -591,9 +573,9 @@ class _PacketLogCard extends StatelessWidget {
|
||||
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
|
||||
),
|
||||
if (rxInfo?.snrDb != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(height: 8),
|
||||
_SignalMeter(
|
||||
label: 'SNR',
|
||||
label: AppLocalizations.of(context)!.snr,
|
||||
valueLabel:
|
||||
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
||||
normalized: _normalizeSnr(rxInfo.snrDb!),
|
||||
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isRx && decodedRoute != null) ...[
|
||||
if (isRx) ...[
|
||||
const SizedBox(height: 12),
|
||||
_RouteSection(
|
||||
route: decodedRoute,
|
||||
path: resolvedPath ?? const [],
|
||||
originalSender: originalSender,
|
||||
),
|
||||
_DecodedRouteSection(log: log),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
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 {
|
||||
final DecodedLogRxRoute route;
|
||||
final List<ResolvedNodeHash> path;
|
||||
final ResolvedNodeHash? originalSender;
|
||||
|
||||
const _RouteSection({
|
||||
required this.route,
|
||||
required this.path,
|
||||
required this.originalSender,
|
||||
});
|
||||
const _RouteSection({required this.route, required this.path});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -796,20 +854,20 @@ class _RouteSection extends StatelessWidget {
|
||||
children: [
|
||||
_FactCard(
|
||||
icon: Icons.route,
|
||||
label: 'Payload',
|
||||
label: AppLocalizations.of(context)!.payload,
|
||||
value: _payloadTypeLabel(route.payloadType),
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.hub,
|
||||
label: 'Hops',
|
||||
value: '${route.pathHashes.length}',
|
||||
label: AppLocalizations.of(context)!.hops,
|
||||
value: '${route.hopCount}',
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.tag,
|
||||
label: AppLocalizations.of(context)!.hashSize,
|
||||
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),
|
||||
|
||||
251
lib/screens/profiles_screen.dart
Normal file
251
lib/screens/profiles_screen.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../services/profile_manager.dart';
|
||||
import '../services/profile_workspace_coordinator.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class ProfilesScreen extends StatelessWidget {
|
||||
const ProfilesScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ProfileManager>(
|
||||
builder: (context, profileManager, child) {
|
||||
final profiles = profileManager.visibleProfiles;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.profiles),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.importProfileFromFile();
|
||||
},
|
||||
icon: const Icon(Icons.file_open),
|
||||
tooltip: 'Import profile',
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createProfile(context),
|
||||
icon: Icon(Icons.add),
|
||||
label: Text(AppLocalizations.of(context)!.newProfile),
|
||||
),
|
||||
body: profiles.isEmpty
|
||||
? Center(
|
||||
child: Text(AppLocalizations.of(context)!.enableProfilesToStartManagingThem),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: profiles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final profile = profiles[index];
|
||||
final isActive =
|
||||
profileManager.activeProfileId == profile.id;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
profile.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (isActive)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(AppLocalizations.of(context)!.active),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_summary(profile)),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.openProfile(profile.id);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.open),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () async {
|
||||
final resolved =
|
||||
profile.id ==
|
||||
ConfigProfile.defaultProfileId
|
||||
? await context
|
||||
.read<
|
||||
ProfileWorkspaceCoordinator
|
||||
>()
|
||||
.snapshotCurrentProfile(
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
)
|
||||
: profile;
|
||||
if (!context.mounted) return;
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.exportProfile(resolved);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.share),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'duplicate':
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.duplicateProfile(profile);
|
||||
break;
|
||||
case 'rename':
|
||||
await _renameProfile(context, profile);
|
||||
break;
|
||||
case 'delete':
|
||||
await context
|
||||
.read<ProfileWorkspaceCoordinator>()
|
||||
.deleteProfile(profile);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'duplicate',
|
||||
child: Text(AppLocalizations.of(context)!.duplicate),
|
||||
),
|
||||
if (!profile.isDefault)
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Text(AppLocalizations.of(context)!.rename),
|
||||
),
|
||||
if (!profile.isDefault)
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _summary(ConfigProfile profile) {
|
||||
if (profile.isDefault) {
|
||||
return 'Current app state and history.';
|
||||
}
|
||||
|
||||
final sections = <String>[];
|
||||
if (profile.sections.deviceConfig?.isEmpty == false) {
|
||||
sections.add('Device');
|
||||
}
|
||||
if (profile.sections.channels.isNotEmpty) {
|
||||
sections.add('${profile.sections.channels.length} channels');
|
||||
}
|
||||
if (profile.sections.appSettings?.isEmpty == false) {
|
||||
sections.add('App settings');
|
||||
}
|
||||
if (profile.sections.mapWorkspace?.isEmpty == false) {
|
||||
sections.add('Map workspace');
|
||||
}
|
||||
return sections.isEmpty ? 'Empty profile' : sections.join(' | ');
|
||||
}
|
||||
|
||||
Future<void> _createProfile(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.createProfile),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: Text(AppLocalizations.of(context)!.create),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<ProfileWorkspaceCoordinator>().createProfileFromCurrent(
|
||||
name: name,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _renameProfile(
|
||||
BuildContext context,
|
||||
ConfigProfile profile,
|
||||
) async {
|
||||
final controller = TextEditingController(text: profile.name);
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.renameProfile),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (name == null || name.isEmpty || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
await context.read<ProfileWorkspaceCoordinator>().renameProfile(
|
||||
profile,
|
||||
name,
|
||||
);
|
||||
}
|
||||
}
|
||||
776
lib/screens/repeaters_map_screen.dart
Normal file
776
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,776 @@
|
||||
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';
|
||||
import '../l10n/app_localizations.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),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_InfoLine(
|
||||
label: AppLocalizations.of(context)!.publicKeyLabel,
|
||||
value: repeater.publicKey,
|
||||
monospace: true,
|
||||
),
|
||||
_InfoLine(
|
||||
label: AppLocalizations.of(context)!.coordinates,
|
||||
value:
|
||||
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||
),
|
||||
_InfoLine(label: AppLocalizations.of(context)!.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: Icon(Icons.check_circle),
|
||||
label: Text(AppLocalizations.of(context)!.alreadyInContacts),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts),
|
||||
),
|
||||
);
|
||||
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: Text(AppLocalizations.of(context)!.repeatersMap),
|
||||
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: [
|
||||
_LegendRow(
|
||||
color: const Color(0xFF7C3AED),
|
||||
label: AppLocalizations.of(context)!.fromContacts,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LegendRow(
|
||||
color: const Color(0xFFE8681D),
|
||||
label: AppLocalizations.of(context)!.onlineOnly,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
||||
PopupMenuItem(
|
||||
value: 'import',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.download),
|
||||
leading: Icon(Icons.download),
|
||||
title: Text(l10n.importFromClipboard),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
@@ -248,7 +248,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
||||
PopupMenuItem(
|
||||
value: 'export',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.upload),
|
||||
leading: Icon(Icons.upload),
|
||||
title: Text(l10n.exportToClipboard),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
@@ -257,7 +257,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
||||
PopupMenuItem(
|
||||
value: 'reset',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.restart_alt),
|
||||
leading: Icon(Icons.restart_alt),
|
||||
title: Text(l10n.resetToDefaults),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
@@ -283,14 +283,14 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.noTemplates,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.tapAddToCreate,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
@@ -317,7 +317,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addTemplate,
|
||||
icon: const Icon(Icons.add),
|
||||
icon: Icon(Icons.add),
|
||||
label: Text(l10n.addTemplate),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../models/device_info.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../widgets/device/spectrum_scan_panel.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
class SpectrumScanScreen extends StatefulWidget {
|
||||
const SpectrumScanScreen({super.key});
|
||||
@@ -323,8 +324,8 @@ class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
||||
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
||||
if (mergedCandidates.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Spectrum scan returned no candidate frequencies'),
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.spectrumScanReturnedNoCandidateFrequencies),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -383,7 +384,7 @@ class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
||||
: possibleBracketFrequencies;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Spectrum Scan')),
|
||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.spectrumScan)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/wizard_preferences.dart';
|
||||
import '../widgets/connection_dialog.dart';
|
||||
|
||||
/// Welcome wizard screen to introduce new users to the app
|
||||
class WelcomeWizardScreen extends StatefulWidget {
|
||||
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
|
||||
|
||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
final TextEditingController _deviceNameController = TextEditingController();
|
||||
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
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_deviceNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
if (_currentPage == 1) {
|
||||
_handleDeviceSetupAction();
|
||||
} else if (_currentPage < _totalPages - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -68,14 +324,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
children: [
|
||||
// Top bar with skip button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (_currentPage > 0)
|
||||
TextButton.icon(
|
||||
onPressed: _previousPage,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
icon: Icon(Icons.arrow_back),
|
||||
label: Text(l10n.wizardBack),
|
||||
)
|
||||
else
|
||||
@@ -96,8 +352,10 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_buildWelcomePage(context, l10n, colorScheme),
|
||||
_buildConnectDevicePage(context, l10n, colorScheme),
|
||||
_buildConnectingPage(context, l10n, colorScheme),
|
||||
_buildChannelPage(context, l10n, colorScheme),
|
||||
_buildContactsPage(context, l10n, colorScheme),
|
||||
@@ -108,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
|
||||
// Page indicators
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
@@ -130,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
|
||||
// Next/Get Started button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _nextPage,
|
||||
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -142,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_currentPage < _totalPages - 1
|
||||
_currentPage == 1
|
||||
? _deviceSetupButtonLabel(context)
|
||||
: _currentPage < _totalPages - 1
|
||||
? l10n.wizardNext
|
||||
: l10n.wizardGetStarted,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
@@ -165,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
icon: Icons.waving_hand,
|
||||
iconColor: Colors.orange,
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -176,33 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.bluetooth_searching,
|
||||
icon: Icons.forum_rounded,
|
||||
iconColor: Colors.blue,
|
||||
title: l10n.wizardConnectingTitle,
|
||||
description: l10n.wizardConnectingDescription,
|
||||
title: l10n.wizardMessagingTitle,
|
||||
description: l10n.wizardMessagingDescription,
|
||||
features: [
|
||||
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
|
||||
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
|
||||
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
|
||||
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
|
||||
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
|
||||
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
|
||||
],
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.campaign,
|
||||
icon: Icons.groups_rounded,
|
||||
iconColor: Colors.purple,
|
||||
title: l10n.wizardChannelTitle,
|
||||
description: l10n.wizardChannelDescription,
|
||||
title: l10n.wizardNetworkTitle,
|
||||
description: l10n.wizardNetworkDescription,
|
||||
features: [
|
||||
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
|
||||
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
|
||||
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
|
||||
_FeatureItem(
|
||||
icon: Icons.person_add_alt_1,
|
||||
text: l10n.wizardNetworkFeature1,
|
||||
),
|
||||
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
|
||||
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
@@ -214,17 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.people,
|
||||
icon: Icons.map_rounded,
|
||||
iconColor: Colors.teal,
|
||||
title: l10n.wizardContactsTitle,
|
||||
description: l10n.wizardContactsDescription,
|
||||
title: l10n.wizardMapOpsTitle,
|
||||
description: l10n.wizardMapOpsDescription,
|
||||
features: [
|
||||
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
|
||||
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
|
||||
_FeatureItem(
|
||||
icon: Icons.battery_std,
|
||||
text: l10n.wizardContactsFeature3,
|
||||
),
|
||||
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
|
||||
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
|
||||
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
@@ -236,18 +699,17 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.map,
|
||||
icon: Icons.tune_rounded,
|
||||
iconColor: Colors.red,
|
||||
title: l10n.wizardMapTitle,
|
||||
description: l10n.wizardMapDescription,
|
||||
title: l10n.wizardToolsTitle,
|
||||
description: l10n.wizardToolsDescription,
|
||||
features: [
|
||||
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
|
||||
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
|
||||
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
|
||||
_FeatureItem(
|
||||
icon: Icons.person_pin_circle,
|
||||
text: l10n.wizardMapFeature2,
|
||||
icon: Icons.analytics_outlined,
|
||||
text: l10n.wizardToolsFeature3,
|
||||
),
|
||||
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
|
||||
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
@@ -261,69 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
List<_FeatureItem>? features,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
// Icon
|
||||
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),
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 560,
|
||||
maxHeight: constraints.maxHeight,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildPageHeader(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
description: description,
|
||||
badge: l10n.wizardOverviewBadge,
|
||||
),
|
||||
),
|
||||
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 {
|
||||
@@ -332,3 +874,23 @@ class _FeatureItem {
|
||||
|
||||
_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,
|
||||
});
|
||||
}
|
||||
|
||||
156
lib/services/app_config_snapshot_service.dart
Normal file
156
lib/services/app_config_snapshot_service.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/app_provider.dart';
|
||||
import 'image_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'route_hash_preferences.dart';
|
||||
import 'voice_bitrate_preferences.dart';
|
||||
|
||||
class AppConfigSnapshotService {
|
||||
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
return AppSettingsProfileSection(
|
||||
mapEnabled: appProvider.isMapEnabled,
|
||||
contactsEnabled: appProvider.isContactsEnabled,
|
||||
sensorsEnabled: appProvider.isSensorsEnabled,
|
||||
voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||
voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||
voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||
voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||
voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
|
||||
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||
messageFontScale: appProvider.messageFontScale,
|
||||
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
|
||||
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
|
||||
routeHashSize: await RouteHashPreferences.getHashSize(),
|
||||
imageMaxSize: await ImagePreferences.getMaxSize(),
|
||||
imageCompression: await ImagePreferences.getCompression(),
|
||||
imageGrayscale: await ImagePreferences.getGrayscale(),
|
||||
imageUltraMode: await ImagePreferences.getUltraMode(),
|
||||
showRxTxIndicators:
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
) ??
|
||||
true,
|
||||
fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled,
|
||||
fastLocationMovementThresholdMeters:
|
||||
locationTracking.fastLocationMovementThresholdMeters,
|
||||
fastLocationActiveCadenceSeconds:
|
||||
locationTracking.fastLocationActiveCadenceSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
AppSettingsProfileSection? section,
|
||||
AppProvider appProvider,
|
||||
) async {
|
||||
if (section == null || section.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (section.mapEnabled != null) {
|
||||
await appProvider.toggleMapEnabled(section.mapEnabled!);
|
||||
}
|
||||
if (section.contactsEnabled != null) {
|
||||
await appProvider.toggleContactsEnabled(section.contactsEnabled!);
|
||||
}
|
||||
if (section.sensorsEnabled != null) {
|
||||
await appProvider.toggleSensorsEnabled(section.sensorsEnabled!);
|
||||
}
|
||||
if (section.voiceSilenceTrimmingEnabled != null) {
|
||||
await appProvider.toggleVoiceSilenceTrimmingEnabled(
|
||||
section.voiceSilenceTrimmingEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBandPassFilterEnabled != null) {
|
||||
await appProvider.toggleVoiceBandPassFilterEnabled(
|
||||
section.voiceBandPassFilterEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceCompressorEnabled != null) {
|
||||
await appProvider.toggleVoiceCompressorEnabled(
|
||||
section.voiceCompressorEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceLimiterEnabled != null) {
|
||||
await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!);
|
||||
}
|
||||
if (section.voiceAutoGainEnabled != null) {
|
||||
await appProvider.toggleVoiceAutoGainEnabled(
|
||||
section.voiceAutoGainEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceEchoCancellationEnabled != null) {
|
||||
await appProvider.toggleVoiceEchoCancellationEnabled(
|
||||
section.voiceEchoCancellationEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceNoiseSuppressionEnabled != null) {
|
||||
await appProvider.toggleVoiceNoiseSuppressionEnabled(
|
||||
section.voiceNoiseSuppressionEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.messageFontScale != null) {
|
||||
await appProvider.setMessageFontScale(section.messageFontScale!);
|
||||
}
|
||||
if (section.autoRouteRotationEnabled != null) {
|
||||
await appProvider.toggleAutoRouteRotationEnabled(
|
||||
section.autoRouteRotationEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.clearPathOnMaxRetry != null) {
|
||||
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
|
||||
}
|
||||
if (section.nearestRelayFallbackEnabled != null) {
|
||||
await appProvider.toggleNearestRelayFallbackEnabled(
|
||||
section.nearestRelayFallbackEnabled!,
|
||||
);
|
||||
}
|
||||
if (section.voiceBitrate != null) {
|
||||
await VoiceBitratePreferences.setBitrate(section.voiceBitrate!);
|
||||
}
|
||||
if (section.routeHashSize != null) {
|
||||
await RouteHashPreferences.setHashSize(section.routeHashSize!);
|
||||
}
|
||||
if (section.imageMaxSize != null) {
|
||||
await ImagePreferences.setMaxSize(section.imageMaxSize!);
|
||||
}
|
||||
if (section.imageCompression != null) {
|
||||
await ImagePreferences.setCompression(section.imageCompression!);
|
||||
}
|
||||
if (section.imageGrayscale != null) {
|
||||
await ImagePreferences.setGrayscale(section.imageGrayscale!);
|
||||
}
|
||||
if (section.imageUltraMode != null) {
|
||||
await ImagePreferences.setUltraMode(section.imageUltraMode!);
|
||||
}
|
||||
if (section.showRxTxIndicators != null) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||
section.showRxTxIndicators!,
|
||||
);
|
||||
}
|
||||
|
||||
final locationTracking = appProvider.locationTrackingService;
|
||||
if (section.fastLocationUpdatesEnabled != null) {
|
||||
locationTracking.fastLocationUpdatesEnabled =
|
||||
section.fastLocationUpdatesEnabled!;
|
||||
}
|
||||
if (section.fastLocationMovementThresholdMeters != null) {
|
||||
locationTracking.fastLocationMovementThresholdMeters =
|
||||
section.fastLocationMovementThresholdMeters!;
|
||||
}
|
||||
if (section.fastLocationActiveCadenceSeconds != null) {
|
||||
locationTracking.fastLocationActiveCadenceSeconds =
|
||||
section.fastLocationActiveCadenceSeconds!;
|
||||
}
|
||||
await locationTracking.saveSettings();
|
||||
await appProvider.reloadProfileScopedSettings();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Background location tracking service for SAR operations
|
||||
/// Tracks user location and sends periodic updates via MeshCore BLE
|
||||
@@ -14,6 +16,11 @@ class BackgroundLocationService {
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
|
||||
MeshCoreBleService? _bleService;
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
bool _isInitialized = false;
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
|
||||
@@ -58,18 +65,27 @@ class BackgroundLocationService {
|
||||
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
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, true);
|
||||
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
|
||||
|
||||
// Start listening to position updates
|
||||
Position? lastPosition;
|
||||
try {
|
||||
_positionSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
distanceFilter: distanceThreshold.toInt(),
|
||||
),
|
||||
).listen((Position position) async {
|
||||
@@ -100,8 +116,14 @@ class BackgroundLocationService {
|
||||
lastPosition = position;
|
||||
|
||||
// Save to preferences
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_prefKeyLastLat),
|
||||
position.latitude,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_prefKeyLastLon),
|
||||
position.longitude,
|
||||
);
|
||||
|
||||
// Update device's advertised location
|
||||
if (_bleService != null && _bleService!.isConnected) {
|
||||
@@ -151,7 +173,7 @@ class BackgroundLocationService {
|
||||
_positionSubscription = null;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
||||
}
|
||||
|
||||
@@ -159,16 +181,34 @@ class BackgroundLocationService {
|
||||
/// Note: This will restart tracking with the new threshold
|
||||
Future<void> updateDistanceThreshold(double distance) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyDistance, distance);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyDistance), distance);
|
||||
debugPrint(
|
||||
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
||||
);
|
||||
|
||||
// Restart tracking if currently enabled
|
||||
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
|
||||
final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
|
||||
if (isEnabled && _bleService != null) {
|
||||
await stopTracking();
|
||||
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/foundation.dart';
|
||||
|
||||
/// Service for accessing build information from native platform code
|
||||
/// Currently supports Android only - returns "unknown" for other platforms
|
||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
||||
}
|
||||
|
||||
// Only Android has the platform channel implementation
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
|
||||
@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
|
||||
/// Cayenne LPP (Low Power Payload) data parser
|
||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||
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
|
||||
static ContactTelemetry parse(Uint8List data) {
|
||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||
@@ -25,7 +95,8 @@ class CayenneLppParser {
|
||||
|
||||
int fieldCount = 0;
|
||||
while (reader.hasRemaining) {
|
||||
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||
if (fieldCount > 0 &&
|
||||
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||
debugPrint(
|
||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||
'${data.length - reader.remainingBytesCount}',
|
||||
@@ -65,14 +136,16 @@ class CayenneLppParser {
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Analog Input (raw): $rawValue');
|
||||
debugPrint(' Analog Input (volts): ${value}V');
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
// If this is a battery reading
|
||||
if (channel == 0 || channel == 1) {
|
||||
if (_isBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else {
|
||||
extraSensorData['analog_input_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -85,7 +158,7 @@ class CayenneLppParser {
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppIlluminanceSensor:
|
||||
final value = reader.readUInt16BE();
|
||||
final value = reader.readUInt16BE().toDouble();
|
||||
debugPrint(' Illuminance: $value lux');
|
||||
extraSensorData['illuminance_$channel'] = value;
|
||||
break;
|
||||
@@ -98,18 +171,36 @@ class CayenneLppParser {
|
||||
|
||||
case MeshCoreConstants.lppTemperatureSensor:
|
||||
final rawValue = reader.readInt16BE();
|
||||
temperature = rawValue / 10.0;
|
||||
final value = rawValue / 10.0;
|
||||
debugPrint(' Temperature (raw): $rawValue');
|
||||
debugPrint(
|
||||
' Temperature: ${temperature.toStringAsFixed(1)}°C',
|
||||
);
|
||||
debugPrint(' Temperature: ${value.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;
|
||||
|
||||
case MeshCoreConstants.lppHumiditySensor:
|
||||
final rawValue = reader.readByte();
|
||||
humidity = rawValue / 2.0;
|
||||
final value = rawValue / 2.0;
|
||||
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;
|
||||
|
||||
case MeshCoreConstants.lppAccelerometer:
|
||||
@@ -126,9 +217,19 @@ class CayenneLppParser {
|
||||
|
||||
case MeshCoreConstants.lppBarometer:
|
||||
final rawValue = reader.readUInt16BE();
|
||||
pressure = rawValue / 10.0;
|
||||
final value = rawValue / 10.0;
|
||||
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;
|
||||
|
||||
case MeshCoreConstants.lppVoltageSensor:
|
||||
@@ -136,12 +237,17 @@ class CayenneLppParser {
|
||||
final value = rawValue / 100.0;
|
||||
debugPrint(' Voltage (raw): $rawValue');
|
||||
debugPrint(' Voltage: ${value}V');
|
||||
// Treat voltage sensor as battery reading
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
if (_isBatteryChannel(channel)) {
|
||||
batteryMilliVolts = value * 1000;
|
||||
batteryPercentage = _calculateBatteryPercentage(value);
|
||||
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||
debugPrint(
|
||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||
);
|
||||
} else {
|
||||
extraSensorData['voltage_$channel'] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case MeshCoreConstants.lppGyrometer:
|
||||
@@ -194,15 +300,328 @@ class CayenneLppParser {
|
||||
}
|
||||
|
||||
gpsLocation = LatLng(lat, lon);
|
||||
extraSensorData[_sourceChannelKey('gps')] = channel;
|
||||
extraSensorData['altitude_$channel'] = alt;
|
||||
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:
|
||||
debugPrint(
|
||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
||||
);
|
||||
// Unknown type, skip remaining to avoid parsing errors
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
final size = _payloadSizeForType(type);
|
||||
if (size == null || reader.remainingBytesCount < size) {
|
||||
debugPrint(
|
||||
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||
);
|
||||
reader.skip(reader.remainingBytesCount);
|
||||
break;
|
||||
}
|
||||
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||
reader.skip(size);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -258,6 +677,163 @@ class CayenneLppParser {
|
||||
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) {
|
||||
final start = data.length - remainingBytes;
|
||||
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,16 +2,25 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting contacts to local storage
|
||||
class ContactStorageService {
|
||||
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 _maxStoredPendingAdverts = 500;
|
||||
|
||||
String _key(String baseKey, {String? namespace}) {
|
||||
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||
}
|
||||
|
||||
/// Save contacts to persistent storage
|
||||
Future<void> saveContacts(List<Contact> contacts) async {
|
||||
Future<void> saveContacts(List<Contact> contacts, {String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -26,7 +35,10 @@ class ContactStorageService {
|
||||
: jsonList;
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_contactsKey, jsonString);
|
||||
await prefs.setString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
|
||||
@@ -38,10 +50,15 @@ class ContactStorageService {
|
||||
|
||||
/// Load contacts from persistent storage
|
||||
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
|
||||
Future<List<Contact>> loadContacts({Uint8List? excludePublicKey}) async {
|
||||
Future<List<Contact>> loadContacts({
|
||||
Uint8List? excludePublicKey,
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_contactsKey);
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
debugPrint('ℹ️ [ContactStorage] No stored contacts found');
|
||||
@@ -80,21 +97,158 @@ class ContactStorageService {
|
||||
}
|
||||
|
||||
/// Clear all stored contacts
|
||||
Future<void> clearContacts() async {
|
||||
Future<void> clearContacts({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_contactsKey);
|
||||
await prefs.remove(_key(_contactsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
Future<void> saveContactGroups(
|
||||
List<SavedContactGroup> groups, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_contactsKey);
|
||||
final jsonString = jsonEncode(
|
||||
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||
);
|
||||
await prefs.setString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactGroupsKey, namespace: namespace),
|
||||
);
|
||||
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> clearContactGroups({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_contactGroupsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all contact groups');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> savePendingAdverts(
|
||||
List<Map<String, dynamic>> adverts, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||
: adverts;
|
||||
await prefs.setString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
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({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_pendingAdvertsKey, namespace: namespace),
|
||||
);
|
||||
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({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Favorites ───────────────────────────────────────────────────────────────
|
||||
|
||||
static const String _favoritesKey = 'contact_favorites';
|
||||
|
||||
Future<void> saveFavorites(
|
||||
Set<String> publicKeyHexes, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
publicKeyHexes.toList(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving favorites: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> loadFavorites({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(
|
||||
_key(_favoritesKey, namespace: namespace),
|
||||
);
|
||||
return list?.toSet() ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading favorites: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_contactsKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
@@ -123,6 +277,7 @@ class ContactStorageService {
|
||||
'outPathLen': contact.outPathLen,
|
||||
'outPath': base64Encode(contact.outPath),
|
||||
'advName': contact.advName,
|
||||
'nameOverride': contact.nameOverride,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
@@ -130,6 +285,15 @@ class ContactStorageService {
|
||||
'telemetry': contact.telemetry != null
|
||||
? _telemetryToJson(contact.telemetry!)
|
||||
: null,
|
||||
'advertHistory': contact.advertHistory
|
||||
.map(
|
||||
(point) => {
|
||||
'lat': point.location.latitude,
|
||||
'lon': point.location.longitude,
|
||||
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,6 +309,7 @@ class ContactStorageService {
|
||||
outPathLen: json['outPathLen'] as int,
|
||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||
advName: json['advName'] as String,
|
||||
nameOverride: json['nameOverride'] as String?,
|
||||
lastAdvert: json['lastAdvert'] as int,
|
||||
advLat: json['advLat'] as int,
|
||||
advLon: json['advLon'] as int,
|
||||
@@ -152,6 +317,7 @@ class ContactStorageService {
|
||||
telemetry: json['telemetry'] != null
|
||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||
: null,
|
||||
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||
@@ -203,4 +369,56 @@ class ContactStorageService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
140
lib/services/device_config_applicator.dart
Normal file
140
lib/services/device_config_applicator.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/channels_provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
|
||||
class DeviceConfigApplicator {
|
||||
ConfigProfileSections capture({
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) {
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
return ConfigProfileSections(
|
||||
deviceConfig: DeviceConfigProfileSection(
|
||||
frequencyKhz: deviceInfo.radioFreq,
|
||||
bandwidth: deviceInfo.radioBw,
|
||||
spreadingFactor: deviceInfo.radioSf,
|
||||
codingRate: deviceInfo.radioCr,
|
||||
repeatEnabled: deviceInfo.clientRepeat,
|
||||
txPower: deviceInfo.txPower,
|
||||
telemetryModes: deviceInfo.telemetryModes,
|
||||
advertLocationPolicy: deviceInfo.advertLocPolicy,
|
||||
multiAcks: deviceInfo.multiAcks,
|
||||
manualAddContacts: deviceInfo.manualAddContacts,
|
||||
autoAddUsers: deviceInfo.autoAddUsers,
|
||||
autoAddRepeaters: deviceInfo.autoAddRepeaters,
|
||||
autoAddRoomServers: deviceInfo.autoAddRoomServers,
|
||||
autoAddSensors: deviceInfo.autoAddSensors,
|
||||
autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest,
|
||||
publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat),
|
||||
publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon),
|
||||
),
|
||||
channels: channelsProvider.channels,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
ConfigProfile profile, {
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceConfig = profile.sections.deviceConfig;
|
||||
if (deviceConfig != null && !deviceConfig.isEmpty) {
|
||||
if (deviceConfig.txPower != null) {
|
||||
await connectionProvider.setTxPower(deviceConfig.txPower!);
|
||||
}
|
||||
if (deviceConfig.telemetryModes != null ||
|
||||
deviceConfig.advertLocationPolicy != null ||
|
||||
deviceConfig.manualAddContacts != null ||
|
||||
deviceConfig.multiAcks != null) {
|
||||
await connectionProvider.setOtherParams(
|
||||
manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0,
|
||||
telemetryModes: deviceConfig.telemetryModes ?? 0,
|
||||
advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0,
|
||||
multiAcks: deviceConfig.multiAcks ?? 0,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.autoAddUsers != null &&
|
||||
deviceConfig.autoAddRepeaters != null &&
|
||||
deviceConfig.autoAddRoomServers != null &&
|
||||
deviceConfig.autoAddSensors != null &&
|
||||
deviceConfig.autoAddOverwriteOldest != null) {
|
||||
await connectionProvider.setAutoaddConfig(
|
||||
autoAddUsers: deviceConfig.autoAddUsers!,
|
||||
autoAddRepeaters: deviceConfig.autoAddRepeaters!,
|
||||
autoAddRoomServers: deviceConfig.autoAddRoomServers!,
|
||||
autoAddSensors: deviceConfig.autoAddSensors!,
|
||||
overwriteOldest: deviceConfig.autoAddOverwriteOldest!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.publicLatitude != null &&
|
||||
deviceConfig.publicLongitude != null) {
|
||||
await connectionProvider.setAdvertLatLon(
|
||||
latitude: deviceConfig.publicLatitude!,
|
||||
longitude: deviceConfig.publicLongitude!,
|
||||
);
|
||||
}
|
||||
if (deviceConfig.frequencyKhz != null &&
|
||||
deviceConfig.bandwidth != null &&
|
||||
deviceConfig.spreadingFactor != null &&
|
||||
deviceConfig.codingRate != null) {
|
||||
await connectionProvider.setRadioParams(
|
||||
frequency: deviceConfig.frequencyKhz!,
|
||||
bandwidth: deviceConfig.bandwidth!,
|
||||
spreadingFactor: deviceConfig.spreadingFactor!,
|
||||
codingRate: deviceConfig.codingRate!,
|
||||
repeat: deviceConfig.repeatEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await _applyChannels(
|
||||
channels: profile.sections.channels,
|
||||
connectionProvider: connectionProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
);
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
}
|
||||
|
||||
Future<void> _applyChannels({
|
||||
required List<Channel> channels,
|
||||
required ConnectionProvider connectionProvider,
|
||||
required ChannelsProvider channelsProvider,
|
||||
}) async {
|
||||
if (channels.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final desired = {for (final channel in channels) channel.index: channel};
|
||||
|
||||
for (final channel in channels) {
|
||||
await connectionProvider.setChannelSlot(
|
||||
channelIdx: channel.index,
|
||||
channelName: channel.name,
|
||||
secret: Uint8List.fromList(channel.secret),
|
||||
);
|
||||
channelsProvider.addOrUpdateChannelObject(channel);
|
||||
}
|
||||
|
||||
for (final existing in channelsProvider.channels) {
|
||||
if (existing.index == 0 || desired.containsKey(existing.index)) {
|
||||
continue;
|
||||
}
|
||||
await connectionProvider.deleteChannel(existing.index);
|
||||
channelsProvider.removeChannel(existing.index);
|
||||
}
|
||||
}
|
||||
|
||||
double? _decodeAdvertCoordinate(int? value) {
|
||||
if (value == null || value == 0) {
|
||||
return null;
|
||||
}
|
||||
return value / 1e6;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Stores user-selected image compression settings.
|
||||
class ImagePreferences {
|
||||
@@ -17,44 +18,53 @@ class ImagePreferences {
|
||||
|
||||
static Future<int> getMaxSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize;
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ??
|
||||
defaultMaxSize;
|
||||
return supportedSizes.contains(value) ? value : defaultMaxSize;
|
||||
}
|
||||
|
||||
static Future<void> setMaxSize(int size) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_maxSizeKey, size);
|
||||
await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size);
|
||||
}
|
||||
|
||||
static Future<int> getCompression() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getInt(_qualityKey) ?? defaultQuality;
|
||||
final value =
|
||||
prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ??
|
||||
defaultQuality;
|
||||
return value.clamp(10, 90);
|
||||
}
|
||||
|
||||
static Future<void> setCompression(int compression) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_qualityKey, compression.clamp(10, 90));
|
||||
await prefs.setInt(
|
||||
ProfileStorageScope.scopedKey(_qualityKey),
|
||||
compression.clamp(10, 90),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getGrayscale() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_grayscaleKey) ?? defaultGrayscale;
|
||||
return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ??
|
||||
defaultGrayscale;
|
||||
}
|
||||
|
||||
static Future<void> setGrayscale(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_grayscaleKey, value);
|
||||
await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value);
|
||||
}
|
||||
|
||||
static Future<bool> getUltraMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode;
|
||||
return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ??
|
||||
defaultUltraMode;
|
||||
}
|
||||
|
||||
static Future<void> setUltraMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_ultraModeKey, value);
|
||||
await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value);
|
||||
}
|
||||
|
||||
static int effectiveMaxSize(
|
||||
|
||||
262
lib/services/live_traffic_summary.dart
Normal file
262
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
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 bool isRxDataLog(BlePacketLog log) {
|
||||
return log.direction == PacketDirection.rx &&
|
||||
log.responseCode == logRxDataResponseCode;
|
||||
}
|
||||
|
||||
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
|
||||
return logs.where((log) {
|
||||
return isRxDataLog(log) &&
|
||||
(since == null || !log.timestamp.isBefore(since));
|
||||
}).length;
|
||||
}
|
||||
|
||||
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) =>
|
||||
isRxDataLog(log) && !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('it'), // Italian
|
||||
Locale('el'), // Greek
|
||||
Locale('pt'), // Portuguese
|
||||
Locale('tr'), // Turkish
|
||||
Locale('pl'), // Polish
|
||||
Locale('uk'), // Ukrainian
|
||||
Locale('ru'), // Russian
|
||||
Locale('zh'), // Chinese
|
||||
];
|
||||
@@ -66,6 +70,14 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Greek';
|
||||
case 'pt':
|
||||
return 'Portuguese';
|
||||
case 'tr':
|
||||
return 'Turkish';
|
||||
case 'pl':
|
||||
return 'Polish';
|
||||
case 'uk':
|
||||
return 'Ukrainian';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'zh':
|
||||
@@ -94,6 +106,14 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Ελληνικά';
|
||||
case 'pt':
|
||||
return 'Português';
|
||||
case 'tr':
|
||||
return 'Türkçe';
|
||||
case 'pl':
|
||||
return 'Polski';
|
||||
case 'uk':
|
||||
return 'Українська';
|
||||
case 'ru':
|
||||
return 'Русский';
|
||||
case 'zh':
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
/// Centralized location tracking service for MeshCore SAR
|
||||
///
|
||||
@@ -48,6 +50,10 @@ class LocationTrackingService {
|
||||
static const String _prefKeyFastActiveCadence =
|
||||
'fast_location_active_cadence_seconds';
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Properties
|
||||
// ============================================================================
|
||||
@@ -148,6 +154,10 @@ class LocationTrackingService {
|
||||
/// Check if location permissions are granted
|
||||
Future<bool> checkPermissions() async {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
if (Platform.isIOS) {
|
||||
return permission == LocationPermission.always;
|
||||
}
|
||||
|
||||
return permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
}
|
||||
@@ -181,6 +191,16 @@ class LocationTrackingService {
|
||||
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');
|
||||
return true;
|
||||
}
|
||||
@@ -209,9 +229,9 @@ class LocationTrackingService {
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
timeLimit: timeLimit,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -255,13 +275,35 @@ class LocationTrackingService {
|
||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -362,7 +404,7 @@ class LocationTrackingService {
|
||||
|
||||
// Save disabled state
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, false);
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Tracking stopped');
|
||||
}
|
||||
@@ -440,8 +482,8 @@ class LocationTrackingService {
|
||||
|
||||
// Save to preferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
||||
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
||||
@@ -609,17 +651,24 @@ class LocationTrackingService {
|
||||
Future<void> loadSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
|
||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||
minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0;
|
||||
maxDistanceMeters =
|
||||
prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0;
|
||||
minTimeIntervalSeconds =
|
||||
prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30;
|
||||
gpsUpdateDistance =
|
||||
prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0;
|
||||
fastLocationUpdatesEnabled =
|
||||
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
|
||||
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
|
||||
fastLocationMovementThresholdMeters =
|
||||
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
|
||||
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
|
||||
gpsUpdateDistance)
|
||||
.clamp(1.0, 1000.0);
|
||||
fastLocationActiveCadenceSeconds =
|
||||
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
|
||||
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp(
|
||||
5,
|
||||
60,
|
||||
);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||
@@ -637,21 +686,27 @@ class LocationTrackingService {
|
||||
Future<void> saveSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
|
||||
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
|
||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters);
|
||||
await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters);
|
||||
await prefs.setInt(
|
||||
_scopedKey(_prefKeyMinTimeInterval),
|
||||
minTimeIntervalSeconds,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_scopedKey(_prefKeyGpsUpdateDistance),
|
||||
gpsUpdateDistance,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking);
|
||||
await prefs.setBool(
|
||||
_prefKeyFastLocationEnabled,
|
||||
_scopedKey(_prefKeyFastLocationEnabled),
|
||||
fastLocationUpdatesEnabled,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_prefKeyFastMovementThreshold,
|
||||
_scopedKey(_prefKeyFastMovementThreshold),
|
||||
fastLocationMovementThresholdMeters,
|
||||
);
|
||||
await prefs.setInt(
|
||||
_prefKeyFastActiveCadence,
|
||||
_scopedKey(_prefKeyFastActiveCadence),
|
||||
fastLocationActiveCadenceSeconds,
|
||||
);
|
||||
|
||||
|
||||
@@ -41,90 +41,103 @@ class MapMarkerService {
|
||||
Function(Contact)? onTap,
|
||||
double mapRotation = 0,
|
||||
Position? userPosition,
|
||||
Map<String, LatLng>? estimatedLocations,
|
||||
}) {
|
||||
return contacts.map((contact) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
return contacts
|
||||
.map((contact) {
|
||||
final location = contact.displayLocation ??
|
||||
estimatedLocations?[contact.publicKeyHex];
|
||||
if (location == null) return null;
|
||||
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
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),
|
||||
return Marker(
|
||||
point: location,
|
||||
width: 80,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
child: Transform.rotate(
|
||||
angle: -mapRotation * pi / 180,
|
||||
child: GestureDetector(
|
||||
onTap: onTap != null ? () => onTap(contact) : null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Location update time indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getLocationAgeColor(contact),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
contact.timeSinceLocationUpdate,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
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,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).whereType<Marker>().toList();
|
||||
);
|
||||
})
|
||||
.whereType<Marker>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Generate markers for SAR events.
|
||||
@@ -141,10 +154,11 @@ class MapMarkerService {
|
||||
required BuildContext context,
|
||||
Function(SarMarker)? onTap,
|
||||
double mapRotation = 0,
|
||||
LatLng Function(LatLng point)? pointTransformer,
|
||||
}) {
|
||||
return sarMarkers.map((marker) {
|
||||
return Marker(
|
||||
point: marker.location,
|
||||
point: pointTransformer?.call(marker.location) ?? marker.location,
|
||||
width: 90,
|
||||
height: 100,
|
||||
rotate: false, // Don't rotate the entire marker with map
|
||||
@@ -157,7 +171,10 @@ class MapMarkerService {
|
||||
children: [
|
||||
// Time ago label
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: getSarMarkerColor(marker.type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
@@ -188,7 +205,7 @@ class MapMarkerService {
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
marker.emoji, // Use custom emoji if available
|
||||
marker.emoji, // Use custom emoji if available
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -196,13 +213,17 @@ class MapMarkerService {
|
||||
// Type label
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
||||
marker
|
||||
.displayName, // Uses notes if available, otherwise type.displayName
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
@@ -269,7 +290,8 @@ class MapMarkerService {
|
||||
final dLat = (lat2 - lat1) * 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(lat2 * pi / 180) *
|
||||
sin(dLon / 2) *
|
||||
@@ -299,8 +321,8 @@ class MapMarkerService {
|
||||
final lat2Rad = lat2 * pi / 180;
|
||||
|
||||
final y = sin(dLon) * cos(lat2Rad);
|
||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||
final x =
|
||||
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||
|
||||
final bearing = atan2(y, x) * 180 / pi;
|
||||
return (bearing + 360) % 360;
|
||||
@@ -368,6 +390,8 @@ class MapMarkerService {
|
||||
return Colors.deepPurple; // Purple for repeaters
|
||||
case ContactType.room:
|
||||
return Colors.teal; // Teal for rooms
|
||||
case ContactType.sensor:
|
||||
return Colors.green; // Green for sensors
|
||||
case ContactType.channel:
|
||||
return Colors.orange; // Orange for channels
|
||||
case ContactType.none:
|
||||
@@ -389,6 +413,8 @@ class MapMarkerService {
|
||||
return Icons.router; // Router icon for repeaters
|
||||
case ContactType.room:
|
||||
return Icons.forum; // Forum/chat icon for rooms
|
||||
case ContactType.sensor:
|
||||
return Icons.sensors; // Sensors icon for sensor nodes
|
||||
case ContactType.channel:
|
||||
return Icons.public; // Public icon for channels
|
||||
case ContactType.none:
|
||||
@@ -466,7 +492,8 @@ class MapMarkerService {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
100
lib/services/map_workspace_snapshot_service.dart
Normal file
100
lib/services/map_workspace_snapshot_service.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/config_profile.dart';
|
||||
import '../providers/drawing_provider.dart';
|
||||
import '../providers/map_provider.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
|
||||
class MapWorkspaceSnapshotService {
|
||||
Future<MapWorkspaceProfileSection> capture({
|
||||
required MapProvider mapProvider,
|
||||
required DrawingProvider drawingProvider,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return MapWorkspaceProfileSection(
|
||||
mapPrefs: {
|
||||
'map_rotate_with_heading':
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||
) ??
|
||||
false,
|
||||
'map_show_debug_info':
|
||||
prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('map_show_debug_info'),
|
||||
) ??
|
||||
false,
|
||||
'map_fullscreen':
|
||||
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
|
||||
false,
|
||||
'map_last_latitude': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_latitude'),
|
||||
),
|
||||
'map_last_longitude': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_longitude'),
|
||||
),
|
||||
'map_last_zoom': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_last_zoom'),
|
||||
),
|
||||
'map_last_layer_type': prefs.getInt(
|
||||
ProfileStorageScope.scopedKey('map_last_layer_type'),
|
||||
),
|
||||
'map_gps_update_distance': prefs.getDouble(
|
||||
ProfileStorageScope.scopedKey('map_gps_update_distance'),
|
||||
),
|
||||
'background_tracking_enabled': prefs.getBool(
|
||||
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||
),
|
||||
},
|
||||
drawings: drawingProvider.exportDrawingsJson(),
|
||||
currentTrail: mapProvider.currentTrail?.toJson(),
|
||||
trailHistory: mapProvider.trailHistory
|
||||
.map((trail) => trail.toJson())
|
||||
.toList(),
|
||||
importedTrail: mapProvider.importedTrail?.toJson(),
|
||||
isTrailVisible: mapProvider.isTrailVisible,
|
||||
showCadastralOverlay: mapProvider.showCadastralOverlay,
|
||||
showForestRoadsOverlay: mapProvider.showForestRoadsOverlay,
|
||||
showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay,
|
||||
showMainRoadsOverlay: mapProvider.showMainRoadsOverlay,
|
||||
showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay,
|
||||
showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay,
|
||||
showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay,
|
||||
showFirebreaksOverlay: mapProvider.showFirebreaksOverlay,
|
||||
showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay,
|
||||
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
|
||||
showMunicipalityBordersOverlay:
|
||||
mapProvider.showMunicipalityBordersOverlay,
|
||||
showAllContactTrails: mapProvider.showAllContactTrails,
|
||||
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> apply(
|
||||
MapWorkspaceProfileSection? section, {
|
||||
required MapProvider mapProvider,
|
||||
required DrawingProvider drawingProvider,
|
||||
}) async {
|
||||
if (section == null || section.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mapPrefs = section.mapPrefs ?? const <String, dynamic>{};
|
||||
for (final entry in mapPrefs.entries) {
|
||||
final key = ProfileStorageScope.scopedKey(entry.key);
|
||||
final value = entry.value;
|
||||
if (value == null) {
|
||||
await prefs.remove(key);
|
||||
} else if (value is bool) {
|
||||
await prefs.setBool(key, value);
|
||||
} else if (value is int) {
|
||||
await prefs.setInt(key, value);
|
||||
} else if (value is double) {
|
||||
await prefs.setDouble(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
mapProvider.applyWorkspaceJson(section.toJson());
|
||||
await drawingProvider.replaceDrawingsFromJson(section.drawings);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MeshMapNode {
|
||||
final int type;
|
||||
@@ -18,6 +20,14 @@ class MeshMapNode {
|
||||
required this.updatedAtMs,
|
||||
});
|
||||
|
||||
bool get hasValidCoordinates =>
|
||||
latitude >= -90 &&
|
||||
latitude <= 90 &&
|
||||
longitude >= -180 &&
|
||||
longitude <= 180 &&
|
||||
latitude != 0 &&
|
||||
longitude != 0;
|
||||
|
||||
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
|
||||
return MeshMapNode(
|
||||
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||
@@ -33,25 +43,36 @@ class MeshMapNode {
|
||||
class MeshMapNodesService {
|
||||
static const String _nodesEndpoint =
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||
static const int repeaterType = 1;
|
||||
static const Duration _cacheTtl = Duration(hours: 24);
|
||||
static const Duration traceCacheTtl = _cacheTtl;
|
||||
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 DateTime? _cachedAt;
|
||||
static Future<void>? _ongoingSync;
|
||||
|
||||
static Future<List<MeshMapNode>> fetchNodes({
|
||||
bool forceRefresh = false,
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
bool allowNetwork = true,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (!forceRefresh &&
|
||||
_cachedNodes != null &&
|
||||
cached.isNotEmpty &&
|
||||
_cachedAt != null &&
|
||||
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))
|
||||
.timeout(traceTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
@@ -64,13 +85,134 @@ class MeshMapNodesService {
|
||||
final nodes = nodesRaw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshMapNode.fromJson)
|
||||
.where(
|
||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||
)
|
||||
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||
.toList();
|
||||
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = now;
|
||||
await _storeCache(nodes, cachedAt: now);
|
||||
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.hasValidCoordinates)
|
||||
.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';
|
||||
|
||||
/// Destination types
|
||||
static const String destinationTypeAll = 'all';
|
||||
static const String destinationTypeChannel = 'channel';
|
||||
static const String destinationTypeContact = 'contact';
|
||||
static const String destinationTypeRoom = 'room';
|
||||
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
|
||||
|
||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||
|
||||
return {
|
||||
'type': type,
|
||||
'publicKey': ?publicKey,
|
||||
};
|
||||
return {'type': type, 'publicKey': ?publicKey};
|
||||
}
|
||||
|
||||
/// 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)
|
||||
static Future<void> setDestination(
|
||||
String type, {
|
||||
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
|
||||
/// Get display name for destination type
|
||||
static String getDestinationTypeName(String type) {
|
||||
switch (type) {
|
||||
case destinationTypeAll:
|
||||
return 'All';
|
||||
case destinationTypeChannel:
|
||||
return 'Channel';
|
||||
case destinationTypeContact:
|
||||
|
||||
@@ -5,31 +5,52 @@ import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import 'profiles_feature_service.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||
static const String _messageContactLocationsKey =
|
||||
'stored_message_contact_locations';
|
||||
static const String _messageReceptionDetailsKey =
|
||||
'stored_message_reception_details';
|
||||
static const String _messageTransferDetailsKey =
|
||||
'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
|
||||
|
||||
String _key(String baseKey, {String? namespace}) {
|
||||
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||
}
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert messages to JSON
|
||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||
// 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)
|
||||
final limitedList = jsonList.length > _maxStoredMessages
|
||||
@@ -37,13 +58,17 @@ class MessageStorageService {
|
||||
: jsonList;
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_messagesKey, jsonString);
|
||||
await prefs.setString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
jsonString,
|
||||
);
|
||||
final retainedMessageIds = limitedList
|
||||
.map((entry) => entry['id'] as String)
|
||||
.toSet();
|
||||
final locationJson = <String, dynamic>{};
|
||||
final receptionJson = <String, dynamic>{};
|
||||
final transferJson = <String, dynamic>{};
|
||||
final routeMetadataJson = <String, dynamic>{};
|
||||
for (final entry in messageContactLocations.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
locationJson[entry.key] = entry.value.toJson();
|
||||
@@ -59,18 +84,27 @@ class MessageStorageService {
|
||||
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(
|
||||
_messageContactLocationsKey,
|
||||
_key(_messageContactLocationsKey, namespace: namespace),
|
||||
jsonEncode(locationJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageReceptionDetailsKey,
|
||||
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||
jsonEncode(receptionJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageTransferDetailsKey,
|
||||
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||
jsonEncode(transferJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_key(_messageRouteMetadataKey, namespace: namespace),
|
||||
jsonEncode(routeMetadataJson),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
@@ -80,11 +114,14 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageContactLocation>>
|
||||
loadMessageContactLocations() async {
|
||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageContactLocationsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
@@ -110,29 +147,48 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>>
|
||||
loadMessageReceptionDetails() async {
|
||||
Future<Map<String, MessageReceptionDetails>> loadMessageReceptionDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
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 jsonString = prefs.getString(
|
||||
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||
);
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
if (jsonString != null && jsonString.isNotEmpty) {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
|
||||
namespace: namespace,
|
||||
);
|
||||
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||
result.putIfAbsent(messageId, () => snapshot);
|
||||
});
|
||||
|
||||
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
|
||||
namespace: namespace,
|
||||
);
|
||||
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||
result.putIfAbsent(
|
||||
messageId,
|
||||
() => MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
pathBytes: pathBytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||
@@ -140,11 +196,14 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageTransferDetails>>
|
||||
loadMessageTransferDetails() async {
|
||||
Future<Map<String, MessageTransferDetails>> loadMessageTransferDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageTransferDetailsKey);
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
@@ -170,11 +229,43 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages() async {
|
||||
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata({
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messageRouteMetadataKey, namespace: namespace),
|
||||
);
|
||||
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
|
||||
Future<List<Message>> loadMessages({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
debugPrint('ℹ️ [MessageStorage] No stored messages found');
|
||||
@@ -199,24 +290,67 @@ class MessageStorageService {
|
||||
}
|
||||
|
||||
/// Clear all stored messages
|
||||
Future<void> clearMessages() async {
|
||||
Future<void> clearMessages({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_messagesKey);
|
||||
await prefs.remove(_messageContactLocationsKey);
|
||||
await prefs.remove(_messageReceptionDetailsKey);
|
||||
await prefs.remove(_messageTransferDetailsKey);
|
||||
await prefs.remove(_key(_messagesKey, namespace: namespace));
|
||||
await prefs.remove(
|
||||
_key(_messageContactLocationsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(
|
||||
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(
|
||||
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||
);
|
||||
await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace));
|
||||
await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace));
|
||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
final ids =
|
||||
prefs.getStringList(
|
||||
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||
) ??
|
||||
const [];
|
||||
return ids.toSet();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||
return const <String>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRemovedSarMarkerIds(
|
||||
Set<String> ids, {
|
||||
String? namespace,
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(
|
||||
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||
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
|
||||
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||
@@ -237,7 +371,12 @@ class MessageStorageService {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
@@ -246,12 +385,16 @@ class MessageStorageService {
|
||||
: null,
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'pathBytes': persistedPathBytes?.toList(),
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
'isSarMarker': message.isSarMarker,
|
||||
'sarGpsLat': message.sarGpsCoordinates?.latitude,
|
||||
'sarGpsLon': message.sarGpsCoordinates?.longitude,
|
||||
'sarCustomMapLat': message.sarCustomMapPoint?.latitude,
|
||||
'sarCustomMapLon': message.sarCustomMapPoint?.longitude,
|
||||
'sarCustomMapId': message.sarCustomMapId,
|
||||
'sarNotes': message.sarNotes,
|
||||
'sarCustomEmoji': message.sarCustomEmoji,
|
||||
'sarColorIndex': message.sarColorIndex,
|
||||
@@ -297,9 +440,74 @@ class MessageStorageService {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
if (receptionDetails != null)
|
||||
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
|
||||
String? namespace,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
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({
|
||||
String? namespace,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(
|
||||
_key(_messagesKey, namespace: namespace),
|
||||
);
|
||||
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
|
||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
@@ -316,14 +524,33 @@ class MessageStorageService {
|
||||
: null,
|
||||
channelIdx: json['channelIdx'] 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),
|
||||
senderTimestamp: json['senderTimestamp'] as int,
|
||||
text: json['text'] as String,
|
||||
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
||||
sarGpsCoordinates:
|
||||
json['sarGpsLat'] != null && json['sarGpsLon'] != null
|
||||
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
|
||||
? LatLng(
|
||||
(json['sarGpsLat'] as num).toDouble(),
|
||||
(json['sarGpsLon'] as num).toDouble(),
|
||||
)
|
||||
: null,
|
||||
sarCustomMapPoint:
|
||||
json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null
|
||||
? LatLng(
|
||||
(json['sarCustomMapLat'] as num).toDouble(),
|
||||
(json['sarCustomMapLon'] as num).toDouble(),
|
||||
)
|
||||
: null,
|
||||
sarCustomMapId: json['sarCustomMapId'] as String?,
|
||||
sarNotes: json['sarNotes'] as String?,
|
||||
sarCustomEmoji: json['sarCustomEmoji'] as String?,
|
||||
sarColorIndex: json['sarColorIndex'] as int?,
|
||||
@@ -413,4 +640,12 @@ class MessageStorageService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||
final pathBytes = receptionDetails?.pathBytes;
|
||||
if (pathBytes == null || pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(pathBytes);
|
||||
}
|
||||
}
|
||||
|
||||
63
lib/services/messaging_route_preferences.dart
Normal file
63
lib/services/messaging_route_preferences.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'profiles_feature_service.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(
|
||||
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
|
||||
) ??
|
||||
defaultAutoRouteRotationEnabled;
|
||||
}
|
||||
|
||||
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getClearPathOnMaxRetry() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(
|
||||
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||
) ??
|
||||
defaultClearPathOnMaxRetry;
|
||||
}
|
||||
|
||||
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> getNearestRelayFallbackEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(
|
||||
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||
) ??
|
||||
defaultNearestRelayFallbackEnabled;
|
||||
}
|
||||
|
||||
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user