mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 09:20:29 +00:00
Compare commits
84 Commits
v2026.0307
...
v2026.0312
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
05153c9ceb | ||
|
|
cd508d5f74 | ||
|
|
937f91e496 | ||
|
|
1f826ae4c2 | ||
|
|
2e15ccb3f3 | ||
|
|
aa0777d4a3 | ||
|
|
e904d53d8b | ||
|
|
c4aef05dcf | ||
|
|
1c3af4f927 | ||
|
|
fe2e08c4e4 | ||
|
|
3433eae3a6 | ||
|
|
79c8664d04 | ||
|
|
162376cc47 | ||
|
|
adb8fe32f4 | ||
|
|
5966ddfbc1 | ||
|
|
1867f815d2 | ||
|
|
4010680da0 | ||
|
|
6bfc08f3ae | ||
|
|
fa37a20fc7 | ||
|
|
09f61740d2 | ||
|
|
97c1f3b27d | ||
|
|
fd7d6afb70 | ||
|
|
8a4b29b968 |
4
.github/workflows/build-artifacts.yml
vendored
4
.github/workflows/build-artifacts.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: "3.35.6"
|
FLUTTER_VERSION: "3.41.4"
|
||||||
APP_NAME: meshcore-sar
|
APP_NAME: meshcore-sar
|
||||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build web release
|
- name: Build web release
|
||||||
run: flutter build web --release --base-href /meshcore_sar_app/
|
run: flutter build web --release --base-href /meshcore-sar/
|
||||||
|
|
||||||
- name: Upload pages artifact
|
- name: Upload pages artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.35.6'
|
FLUTTER_VERSION: '3.41.4'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -80,6 +80,7 @@ create_feature_graphic.py
|
|||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- nsd_ios (0.0.1):
|
- nsd_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- ObjectBox (4.4.1)
|
|
||||||
- objectbox_flutter_libs (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- ObjectBox (= 4.4.1)
|
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -103,7 +99,6 @@ DEPENDENCIES:
|
|||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
@@ -118,7 +113,6 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- ObjectBox
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
@@ -149,8 +143,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
nsd_ios:
|
nsd_ios:
|
||||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||||
objectbox_flutter_libs:
|
|
||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -182,12 +174,10 @@ SPEC CHECKSUMS:
|
|||||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 98;
|
CURRENT_PROJECT_VERSION = 112;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>98</string>
|
<string>112</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.489082">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="115.233655">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="296.0895">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
1450
lib/l10n/app_de.arb
1450
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2671
lib/l10n/app_el.arb
2671
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
1967
lib/l10n/app_en.arb
1967
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1439
lib/l10n/app_es.arb
1439
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1444
lib/l10n/app_fr.arb
1444
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
1444
lib/l10n/app_it.arb
1444
lib/l10n/app_it.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1927
lib/l10n/app_localizations_pl.dart
Normal file
1927
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
1934
lib/l10n/app_localizations_pt.dart
Normal file
1934
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
1924
lib/l10n/app_localizations_ru.dart
Normal file
1924
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1917
lib/l10n/app_localizations_tr.dart
Normal file
1917
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
1930
lib/l10n/app_localizations_uk.dart
Normal file
1930
lib/l10n/app_localizations_uk.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_pl.arb
Normal file
2332
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_pt.arb
Normal file
2332
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
567
lib/l10n/app_ru.arb
Normal file
567
lib/l10n/app_ru.arb
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
{
|
||||||
|
"@@locale": "ru",
|
||||||
|
"appTitle": "MeshCore SAR",
|
||||||
|
"messages": "Сообщения",
|
||||||
|
"contacts": "Контакты",
|
||||||
|
"map": "Карта",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"connect": "Подключить",
|
||||||
|
"disconnect": "Отключить",
|
||||||
|
"noDevicesFound": "Устройства не найдены",
|
||||||
|
"scanAgain": "Повторить поиск",
|
||||||
|
"tapToConnect": "Нажмите для подключения",
|
||||||
|
"deviceNotConnected": "Устройство не подключено",
|
||||||
|
"locationPermissionDenied": "Доступ к геолокации запрещён",
|
||||||
|
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
|
||||||
|
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
|
||||||
|
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
|
||||||
|
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
|
||||||
|
"failedToAdvertise": "Ошибка трансляции: {error}",
|
||||||
|
"cancelReconnection": "Отменить переподключение",
|
||||||
|
"general": "Основные",
|
||||||
|
"theme": "Тема",
|
||||||
|
"chooseTheme": "Выбрать тему",
|
||||||
|
"light": "Светлая",
|
||||||
|
"dark": "Тёмная",
|
||||||
|
"blueLightTheme": "Синяя светлая тема",
|
||||||
|
"blueDarkTheme": "Синяя тёмная тема",
|
||||||
|
"sarRed": "SAR Красная",
|
||||||
|
"alertEmergencyMode": "Режим тревоги / ЧС",
|
||||||
|
"sarGreen": "SAR Зелёная",
|
||||||
|
"safeAllClearMode": "Режим «Всё в порядке»",
|
||||||
|
"autoSystem": "Авто (Система)",
|
||||||
|
"followSystemTheme": "Следовать системной теме",
|
||||||
|
"showRxTxIndicators": "Показывать индикаторы RX/TX",
|
||||||
|
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
|
||||||
|
"disableMap": "Отключить карту",
|
||||||
|
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
|
||||||
|
"language": "Язык",
|
||||||
|
"chooseLanguage": "Выбрать язык",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"about": "О приложении",
|
||||||
|
"appVersion": "Версия приложения",
|
||||||
|
"appName": "Название приложения",
|
||||||
|
"aboutMeshCoreSar": "О MeshCore SAR",
|
||||||
|
"aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн",
|
||||||
|
"technologiesUsed": "Используемые технологии:",
|
||||||
|
"technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища",
|
||||||
|
"moreInfo": "Подробнее",
|
||||||
|
"packageName": "Имя пакета",
|
||||||
|
"sampleData": "Тестовые данные",
|
||||||
|
"sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR",
|
||||||
|
"loadSampleData": "Загрузить тестовые данные",
|
||||||
|
"clearAllData": "Очистить все данные",
|
||||||
|
"clearAllDataConfirmTitle": "Очистить все данные",
|
||||||
|
"clearAllDataConfirmMessage": "Это удалит все контакты и маркеры SAR. Вы уверены?",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"loadedSampleData": "Загружено: {teamCount} членов команды, {channelCount} каналов, {sarCount} маркеров SAR, {messageCount} сообщений",
|
||||||
|
"failedToLoadSampleData": "Не удалось загрузить тестовые данные: {error}",
|
||||||
|
"allDataCleared": "Все данные очищены",
|
||||||
|
"failedToStartBackgroundTracking": "Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.",
|
||||||
|
"locationBroadcast": "Трансляция геолокации: {latitude}, {longitude}",
|
||||||
|
"defaultPinInfo": "PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.",
|
||||||
|
"noMessagesYet": "Сообщений пока нет",
|
||||||
|
"pullDownToSync": "Потяните вниз для синхронизации сообщений",
|
||||||
|
"deleteContact": "Удалить контакт",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"viewOnMap": "Показать на карте",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"resetPath": "Сбросить маршрут (перепроложить)",
|
||||||
|
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
|
||||||
|
"copiedToClipboard": "{label} скопировано в буфер обмена",
|
||||||
|
"pleaseEnterPassword": "Пожалуйста, введите пароль",
|
||||||
|
"failedToSyncContacts": "Не удалось синхронизировать контакты: {error}",
|
||||||
|
"loggedInSuccessfully": "Вход выполнен! Ожидание сообщений комнаты...",
|
||||||
|
"loginFailed": "Ошибка входа — неверный пароль",
|
||||||
|
"loggingIn": "Вход в {roomName}...",
|
||||||
|
"failedToSendLogin": "Не удалось отправить данные входа: {error}",
|
||||||
|
"lowLocationAccuracy": "Низкая точность геолокации",
|
||||||
|
"continue_": "Продолжить",
|
||||||
|
"sendSarMarker": "Отправить маркер SAR",
|
||||||
|
"deleteDrawing": "Удалить рисунок",
|
||||||
|
"drawingTools": "Инструменты рисования",
|
||||||
|
"drawLine": "Нарисовать линию",
|
||||||
|
"drawLineDesc": "Нарисуйте произвольную линию на карте",
|
||||||
|
"drawRectangle": "Нарисовать прямоугольник",
|
||||||
|
"drawRectangleDesc": "Нарисуйте прямоугольную область на карте",
|
||||||
|
"measureDistance": "Измерить расстояние",
|
||||||
|
"measureDistanceDesc": "Долгое нажатие на две точки для измерения",
|
||||||
|
"clearMeasurement": "Сбросить измерение",
|
||||||
|
"distanceLabel": "Расстояние: {distance}",
|
||||||
|
"longPressForSecondPoint": "Долгое нажатие для второй точки",
|
||||||
|
"longPressToStartMeasurement": "Долгое нажатие для установки первой точки",
|
||||||
|
"longPressToStartNewMeasurement": "Долгое нажатие для начала нового измерения",
|
||||||
|
"shareDrawings": "Поделиться рисунками",
|
||||||
|
"clearAllDrawings": "Удалить все рисунки",
|
||||||
|
"completeLine": "Завершить линию",
|
||||||
|
"broadcastDrawingsToTeam": "Транслировать {count} рисун{plural} команде",
|
||||||
|
"removeAllDrawings": "Удалить все {count} рисун{plural}",
|
||||||
|
"deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?",
|
||||||
|
"drawing": "Рисунок",
|
||||||
|
"shareDrawingsCount": "Поделиться {count} рисун{plural}",
|
||||||
|
"showReceivedDrawings": "Показать полученные рисунки",
|
||||||
|
"showingAllDrawings": "Показаны все рисунки",
|
||||||
|
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
|
||||||
|
"showSarMarkers": "Показать маркеры SAR",
|
||||||
|
"showingSarMarkers": "Маркеры SAR отображаются",
|
||||||
|
"hidingSarMarkers": "Маркеры SAR скрыты",
|
||||||
|
"clearAll": "Очистить всё",
|
||||||
|
"publicChannel": "Публичный канал",
|
||||||
|
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
|
||||||
|
"storedPermanently": "Сохранено постоянно в комнате",
|
||||||
|
"notConnectedToDevice": "Устройство не подключено",
|
||||||
|
"typeYourMessage": "Введите сообщение...",
|
||||||
|
"quickLocationMarker": "Быстрый маркер местоположения",
|
||||||
|
"markerType": "Тип маркера",
|
||||||
|
"sendTo": "Отправить в",
|
||||||
|
"noDestinationsAvailable": "Нет доступных получателей.",
|
||||||
|
"selectDestination": "Выберите получателя...",
|
||||||
|
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
|
||||||
|
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
|
||||||
|
"location": "Местоположение",
|
||||||
|
"fromMap": "С карты",
|
||||||
|
"gettingLocation": "Получение местоположения...",
|
||||||
|
"locationError": "Ошибка геолокации",
|
||||||
|
"retry": "Повторить",
|
||||||
|
"refreshLocation": "Обновить местоположение",
|
||||||
|
"accuracyMeters": "Точность: ±{accuracy}м",
|
||||||
|
"notesOptional": "Заметки (необязательно)",
|
||||||
|
"addAdditionalInformation": "Добавьте дополнительную информацию...",
|
||||||
|
"lowAccuracyWarning": "Точность геолокации: ±{accuracy}м. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?",
|
||||||
|
"loginToRoom": "Войти в комнату",
|
||||||
|
"enterPasswordInfo": "Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.",
|
||||||
|
"password": "Пароль",
|
||||||
|
"enterRoomPassword": "Введите пароль комнаты",
|
||||||
|
"loggingInDots": "Вход...",
|
||||||
|
"login": "Войти",
|
||||||
|
"failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.",
|
||||||
|
"direct": "Напрямую",
|
||||||
|
"flood": "Широковещательно",
|
||||||
|
"loggedIn": "Вход выполнен",
|
||||||
|
"noGpsData": "Нет данных GPS",
|
||||||
|
"distance": "Расстояние",
|
||||||
|
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
|
||||||
|
"pingFailed": "Пинг не удался: {name} — ответ не получен",
|
||||||
|
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
|
||||||
|
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
|
||||||
|
"type": "Тип",
|
||||||
|
"publicKey": "Публичный ключ",
|
||||||
|
"lastSeen": "Последнее появление",
|
||||||
|
"roomStatus": "Статус комнаты",
|
||||||
|
"loginStatus": "Статус входа",
|
||||||
|
"notLoggedIn": "Не выполнен вход",
|
||||||
|
"adminAccess": "Доступ администратора",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"permissions": "Разрешения",
|
||||||
|
"passwordSaved": "Пароль сохранён",
|
||||||
|
"locationColon": "Местоположение:",
|
||||||
|
"telemetry": "Телеметрия",
|
||||||
|
"voltage": "Напряжение",
|
||||||
|
"battery": "Батарея",
|
||||||
|
"temperature": "Температура",
|
||||||
|
"humidity": "Влажность",
|
||||||
|
"pressure": "Давление",
|
||||||
|
"gpsTelemetry": "GPS (телеметрия)",
|
||||||
|
"updated": "Обновлено",
|
||||||
|
"pathResetInfo": "Маршрут сброшен для {name}. Следующее сообщение найдёт новый путь.",
|
||||||
|
"reLoginToRoom": "Войти в комнату повторно",
|
||||||
|
"heading": "Курс",
|
||||||
|
"elevation": "Высота",
|
||||||
|
"accuracy": "Точность",
|
||||||
|
"bearing": "Азимут",
|
||||||
|
"direction": "Направление",
|
||||||
|
"filterMarkers": "Фильтр маркеров",
|
||||||
|
"filterMarkersTooltip": "Фильтровать маркеры",
|
||||||
|
"contactsFilter": "Контакты",
|
||||||
|
"repeatersFilter": "Ретрансляторы",
|
||||||
|
"sarMarkers": "Маркеры SAR",
|
||||||
|
"foundPerson": "Найденный человек",
|
||||||
|
"fire": "Пожар",
|
||||||
|
"stagingArea": "Место сбора",
|
||||||
|
"showAll": "Показать все",
|
||||||
|
"locationUnavailable": "Местоположение недоступно",
|
||||||
|
"ahead": "впереди",
|
||||||
|
"degreesRight": "{degrees}° вправо",
|
||||||
|
"degreesLeft": "{degrees}° влево",
|
||||||
|
"latLonFormat": "Ш: {latitude} Д: {longitude}",
|
||||||
|
"noContactsYet": "Контактов пока нет",
|
||||||
|
"connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов",
|
||||||
|
"teamMembers": "Члены команды",
|
||||||
|
"repeaters": "Ретрансляторы",
|
||||||
|
"rooms": "Комнаты",
|
||||||
|
"channels": "Каналы",
|
||||||
|
"selectMapLayer": "Выбрать слой карты",
|
||||||
|
"openStreetMap": "OpenStreetMap",
|
||||||
|
"openTopoMap": "OpenTopoMap",
|
||||||
|
"esriSatellite": "ESRI Спутник",
|
||||||
|
"googleHybrid": "Google Гибрид",
|
||||||
|
"googleRoadmap": "Google Дороги",
|
||||||
|
"googleTerrain": "Google Рельеф",
|
||||||
|
"dragToPosition": "Перетащите для позиционирования",
|
||||||
|
"createSarMarker": "Создать маркер SAR",
|
||||||
|
"compass": "Компас",
|
||||||
|
"navigationAndContacts": "Навигация и контакты",
|
||||||
|
"sarAlert": "ТРЕВОГА SAR",
|
||||||
|
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
|
||||||
|
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
|
||||||
|
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
|
||||||
|
"copyText": "Копировать текст",
|
||||||
|
"saveAsTemplate": "Сохранить как шаблон",
|
||||||
|
"templateSaved": "Шаблон успешно сохранён",
|
||||||
|
"templateAlreadyExists": "Шаблон с таким эмодзи уже существует",
|
||||||
|
"deleteMessage": "Удалить сообщение",
|
||||||
|
"deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?",
|
||||||
|
"shareLocation": "Поделиться местоположением",
|
||||||
|
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
|
||||||
|
"sarLocationShare": "Местоположение SAR",
|
||||||
|
"justNow": "Только что",
|
||||||
|
"minutesAgo": "{minutes}м назад",
|
||||||
|
"hoursAgo": "{hours}ч назад",
|
||||||
|
"daysAgo": "{days}д назад",
|
||||||
|
"secondsAgo": "{seconds}с назад",
|
||||||
|
"sending": "Отправка...",
|
||||||
|
"sent": "Отправлено",
|
||||||
|
"delivered": "Доставлено",
|
||||||
|
"deliveredWithTime": "Доставлено ({time}мс)",
|
||||||
|
"failed": "Ошибка",
|
||||||
|
"broadcast": "Трансляция",
|
||||||
|
"deliveredToContacts": "Доставлено {delivered}/{total} контактам",
|
||||||
|
"allDelivered": "Все доставлено",
|
||||||
|
"recipientDetails": "Детали получателей",
|
||||||
|
"pending": "Ожидание",
|
||||||
|
"sarMarkerFoundPerson": "Найденный человек",
|
||||||
|
"sarMarkerFire": "Место пожара",
|
||||||
|
"sarMarkerStagingArea": "Место сбора",
|
||||||
|
"sarMarkerObject": "Найденный объект",
|
||||||
|
"from": "От",
|
||||||
|
"coordinates": "Координаты",
|
||||||
|
"tapToViewOnMap": "Нажмите, чтобы открыть на карте",
|
||||||
|
"radioSettings": "Настройки радио",
|
||||||
|
"frequencyMHz": "Частота (МГц)",
|
||||||
|
"frequencyExample": "например, 869.618",
|
||||||
|
"bandwidth": "Полоса пропускания",
|
||||||
|
"spreadingFactor": "Коэффициент расширения",
|
||||||
|
"codingRate": "Скорость кодирования",
|
||||||
|
"txPowerDbm": "Мощность TX (дБм)",
|
||||||
|
"maxPowerDbm": "Макс: {power} дБм",
|
||||||
|
"you": "Вы",
|
||||||
|
"exportFailed": "Ошибка экспорта: {error}",
|
||||||
|
"importFailed": "Ошибка импорта: {error}",
|
||||||
|
"unknown": "Неизвестно",
|
||||||
|
"onlineLayers": "Онлайн-слои",
|
||||||
|
"locationTrail": "Трек местоположения",
|
||||||
|
"showTrailOnMap": "Показать трек на карте",
|
||||||
|
"trailVisible": "Трек отображается на карте",
|
||||||
|
"trailHiddenRecording": "Трек скрыт (запись продолжается)",
|
||||||
|
"duration": "Продолжительность",
|
||||||
|
"points": "Точки",
|
||||||
|
"clearTrail": "Очистить трек",
|
||||||
|
"clearTrailQuestion": "Очистить трек?",
|
||||||
|
"clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.",
|
||||||
|
"noTrailRecorded": "Трек ещё не записан",
|
||||||
|
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
|
||||||
|
"trailControls": "Управление треком",
|
||||||
|
"contactTrails": "Треки контактов",
|
||||||
|
"showAllContactTrails": "Показать все треки контактов",
|
||||||
|
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
|
||||||
|
"showingTrailsForContacts": "Показаны треки для {count} контактов",
|
||||||
|
"individualContactTrails": "Индивидуальные треки контактов",
|
||||||
|
"deviceInformation": "Информация об устройстве",
|
||||||
|
"bleName": "Имя BLE",
|
||||||
|
"meshName": "Имя в сети Mesh",
|
||||||
|
"notSet": "Не задано",
|
||||||
|
"model": "Модель",
|
||||||
|
"version": "Версия",
|
||||||
|
"buildDate": "Дата сборки",
|
||||||
|
"firmware": "Прошивка",
|
||||||
|
"maxContacts": "Макс. контактов",
|
||||||
|
"maxChannels": "Макс. каналов",
|
||||||
|
"publicInfo": "Публичная информация",
|
||||||
|
"meshNetworkName": "Название сети Mesh",
|
||||||
|
"nameBroadcastInMesh": "Имя, транслируемое в mesh-объявлениях",
|
||||||
|
"telemetryAndLocationSharing": "Телеметрия и передача местоположения",
|
||||||
|
"lat": "Ш",
|
||||||
|
"lon": "Д",
|
||||||
|
"useCurrentLocation": "Использовать текущее местоположение",
|
||||||
|
"noneUnknown": "Нет/Неизвестно",
|
||||||
|
"chatNode": "Узел чата",
|
||||||
|
"repeater": "Ретранслятор",
|
||||||
|
"roomChannel": "Комната/Канал",
|
||||||
|
"typeNumber": "Тип {number}",
|
||||||
|
"copiedToClipboardShort": "{label} скопировано в буфер обмена",
|
||||||
|
"failedToSave": "Не удалось сохранить: {error}",
|
||||||
|
"failedToGetLocation": "Не удалось получить местоположение: {error}",
|
||||||
|
"sarTemplates": "Шаблоны SAR",
|
||||||
|
"manageSarTemplates": "Управление шаблонами целеуказания",
|
||||||
|
"addTemplate": "Добавить шаблон",
|
||||||
|
"editTemplate": "Изменить шаблон",
|
||||||
|
"deleteTemplate": "Удалить шаблон",
|
||||||
|
"templateName": "Название шаблона",
|
||||||
|
"templateNameHint": "например, Найденный человек",
|
||||||
|
"templateEmoji": "Эмодзи",
|
||||||
|
"emojiRequired": "Требуется эмодзи",
|
||||||
|
"nameRequired": "Требуется название",
|
||||||
|
"templateDescription": "Описание (необязательно)",
|
||||||
|
"templateDescriptionHint": "Добавьте дополнительный контекст...",
|
||||||
|
"templateColor": "Цвет",
|
||||||
|
"previewFormat": "Предпросмотр (формат сообщения SAR)",
|
||||||
|
"importFromClipboard": "Импорт",
|
||||||
|
"exportToClipboard": "Экспорт",
|
||||||
|
"deleteTemplateConfirmation": "Удалить шаблон '{name}'?",
|
||||||
|
"templateAdded": "Шаблон добавлен",
|
||||||
|
"templateUpdated": "Шаблон обновлён",
|
||||||
|
"templateDeleted": "Шаблон удалён",
|
||||||
|
"templatesImported": "{count, plural, =0{Шаблоны не импортированы} =1{Импортирован 1 шаблон} other{Импортировано {count} шаблонов}}",
|
||||||
|
"templatesExported": "{count, plural, =1{1 шаблон экспортирован в буфер обмена} other{{count} шаблонов экспортировано в буфер обмена}}",
|
||||||
|
"resetToDefaults": "Сбросить до умолчаний",
|
||||||
|
"resetToDefaultsConfirmation": "Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?",
|
||||||
|
"reset": "Сбросить",
|
||||||
|
"resetComplete": "Шаблоны сброшены до умолчаний",
|
||||||
|
"noTemplates": "Шаблоны недоступны",
|
||||||
|
"tapAddToCreate": "Нажмите +, чтобы создать первый шаблон",
|
||||||
|
"ok": "ОК",
|
||||||
|
"permissionsSection": "Разрешения",
|
||||||
|
"locationPermission": "Разрешение геолокации",
|
||||||
|
"checking": "Проверка...",
|
||||||
|
"locationPermissionGrantedAlways": "Разрешено (всегда)",
|
||||||
|
"locationPermissionGrantedWhileInUse": "Разрешено (при использовании)",
|
||||||
|
"locationPermissionDeniedTapToRequest": "Запрещено — нажмите для запроса",
|
||||||
|
"locationPermissionPermanentlyDeniedOpenSettings": "Запрещено навсегда — откройте настройки",
|
||||||
|
"locationPermissionDialogContent": "Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.",
|
||||||
|
"openSettings": "Открыть настройки",
|
||||||
|
"locationPermissionGranted": "Разрешение геолокации получено!",
|
||||||
|
"locationPermissionRequiredForGps": "Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.",
|
||||||
|
"locationPermissionAlreadyGranted": "Разрешение геолокации уже предоставлено.",
|
||||||
|
"sarNavyBlue": "SAR Тёмно-синяя",
|
||||||
|
"sarNavyBlueDescription": "Профессиональный / оперативный режим",
|
||||||
|
"selectRecipient": "Выбрать получателя",
|
||||||
|
"broadcastToAllNearby": "Трансляция всем ближайшим",
|
||||||
|
"searchRecipients": "Поиск получателей...",
|
||||||
|
"noContactsFound": "Контакты не найдены",
|
||||||
|
"noRoomsFound": "Комнаты не найдены",
|
||||||
|
"noRecipientsAvailable": "Нет доступных получателей",
|
||||||
|
"noChannelsFound": "Каналы не найдены",
|
||||||
|
"newMessage": "Новое сообщение",
|
||||||
|
"channel": "Канал",
|
||||||
|
"samplePoliceLead": "Руководитель группы полиции",
|
||||||
|
"sampleDroneOperator": "Оператор дрона",
|
||||||
|
"sampleFirefighterAlpha": "Пожарный",
|
||||||
|
"sampleMedicCharlie": "Медик",
|
||||||
|
"sampleCommandDelta": "Командование",
|
||||||
|
"sampleFireEngine": "Пожарная машина",
|
||||||
|
"sampleAirSupport": "Авиационная поддержка",
|
||||||
|
"sampleBaseCoordinator": "Базовый координатор",
|
||||||
|
"channelEmergency": "Аварийный",
|
||||||
|
"channelCoordination": "Координация",
|
||||||
|
"channelUpdates": "Обновления",
|
||||||
|
"sampleTeamMember": "Тестовый член команды",
|
||||||
|
"sampleScout": "Тестовый разведчик",
|
||||||
|
"sampleBase": "Тестовая база",
|
||||||
|
"sampleSearcher": "Тестовый поисковик",
|
||||||
|
"sampleObjectBackpack": " Найден рюкзак синего цвета",
|
||||||
|
"sampleObjectVehicle": " Брошенный автомобиль — установить владельца",
|
||||||
|
"sampleObjectCamping": " Обнаружено туристическое снаряжение",
|
||||||
|
"sampleObjectTrailMarker": " Указатель тропы найден вне маршрута",
|
||||||
|
"sampleMsgAllTeamsCheckIn": "Всем группам: сообщить о готовности",
|
||||||
|
"sampleMsgWeatherUpdate": "Погода: ясно, температура 18°C",
|
||||||
|
"sampleMsgBaseCamp": "Базовый лагерь развёрнут у места сбора",
|
||||||
|
"sampleMsgTeamAlpha": "Группа выдвигается в сектор 2",
|
||||||
|
"sampleMsgRadioCheck": "Проверка связи — всем станциям ответить",
|
||||||
|
"sampleMsgWaterSupply": "Вода доступна на контрольной точке 3",
|
||||||
|
"sampleMsgTeamBravo": "Группа докладывает: сектор 1 чист",
|
||||||
|
"sampleMsgEtaRallyPoint": "Прибытие на место сбора: 15 минут",
|
||||||
|
"sampleMsgSupplyDrop": "Сброс снаряжения подтверждён на 14:00",
|
||||||
|
"sampleMsgDroneSurvey": "Разведка дроном завершена — ничего не обнаружено",
|
||||||
|
"sampleMsgTeamCharlie": "Группа запрашивает подкрепление",
|
||||||
|
"sampleMsgRadioDiscipline": "Всем подразделениям: соблюдать радиодисциплину",
|
||||||
|
"sampleMsgUrgentMedical": "СРОЧНО: нужна медицинская помощь в секторе 4",
|
||||||
|
"sampleMsgAdultMale": " Взрослый мужчина, в сознании",
|
||||||
|
"sampleMsgFireSpotted": "Обнаружен пожар — координаты следуют",
|
||||||
|
"sampleMsgSpreadingRapidly": " Распространяется быстро!",
|
||||||
|
"sampleMsgPriorityHelicopter": "ПРИОРИТЕТ: нужна поддержка вертолёта",
|
||||||
|
"sampleMsgMedicalTeamEnRoute": "Медицинская группа движется к вашему местоположению",
|
||||||
|
"sampleMsgEvacHelicopter": "Вертолёт эвакуации: прибытие через 10 минут",
|
||||||
|
"sampleMsgEmergencyResolved": "Чрезвычайная ситуация ликвидирована — опасности нет",
|
||||||
|
"sampleMsgEmergencyStagingArea": " Аварийное место сбора",
|
||||||
|
"sampleMsgEmergencyServices": "Аварийные службы уведомлены и реагируют",
|
||||||
|
"sampleAlphaTeamLead": "Руководитель группы",
|
||||||
|
"sampleBravoScout": "Разведчик",
|
||||||
|
"sampleCharlieMedic": "Медик",
|
||||||
|
"sampleDeltaNavigator": "Навигатор",
|
||||||
|
"sampleEchoSupport": "Поддержка",
|
||||||
|
"sampleBaseCommand": "Базовое командование",
|
||||||
|
"sampleFieldCoordinator": "Полевой координатор",
|
||||||
|
"sampleMedicalTeam": "Медицинская группа",
|
||||||
|
"mapDrawing": "Рисунок на карте",
|
||||||
|
"navigateToDrawing": "Перейти к рисунку",
|
||||||
|
"copyCoordinates": "Копировать координаты",
|
||||||
|
"hideFromMap": "Скрыть с карты",
|
||||||
|
"lineDrawing": "Линия",
|
||||||
|
"rectangleDrawing": "Прямоугольник",
|
||||||
|
"manualCoordinates": "Ввод координат вручную",
|
||||||
|
"enterCoordinatesManually": "Ввести координаты вручную",
|
||||||
|
"latitudeLabel": "Широта",
|
||||||
|
"longitudeLabel": "Долгота",
|
||||||
|
"exampleCoordinates": "Пример: 55.7558, 37.6173",
|
||||||
|
"shareDrawing": "Поделиться рисунком",
|
||||||
|
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
|
||||||
|
"shareToRoom": "Отправить в комнату",
|
||||||
|
"sendToPersistentStorage": "Отправить в постоянное хранилище комнаты",
|
||||||
|
"deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?",
|
||||||
|
"drawingDeleted": "Рисунок удалён",
|
||||||
|
"yourDrawingsCount": "Ваши рисунки ({count})",
|
||||||
|
"shared": "Передано",
|
||||||
|
"line": "Линия",
|
||||||
|
"rectangle": "Прямоугольник",
|
||||||
|
"updateAvailable": "Доступно обновление",
|
||||||
|
"currentVersion": "Текущая",
|
||||||
|
"latestVersion": "Последняя",
|
||||||
|
"downloadUpdate": "Скачать",
|
||||||
|
"updateLater": "Позже",
|
||||||
|
"cadastralParcels": "Кадастровые участки",
|
||||||
|
"forestRoads": "Лесные дороги",
|
||||||
|
"wmsOverlays": "WMS-наложения",
|
||||||
|
"hikingTrails": "Туристические маршруты",
|
||||||
|
"mainRoads": "Главные дороги",
|
||||||
|
"houseNumbers": "Номера домов",
|
||||||
|
"fireHazardZones": "Пожароопасные зоны",
|
||||||
|
"historicalFires": "Исторические пожары",
|
||||||
|
"firebreaks": "Противопожарные просеки",
|
||||||
|
"krasFireZones": "Пожарные зоны Краса",
|
||||||
|
"placeNames": "Названия мест",
|
||||||
|
"municipalityBorders": "Границы муниципалитетов",
|
||||||
|
"topographicMap": "Топографическая карта 1:25000",
|
||||||
|
"recentMessages": "Последние сообщения",
|
||||||
|
"addChannel": "Добавить канал",
|
||||||
|
"channelName": "Название канала",
|
||||||
|
"channelNameHint": "например, Группа спасения Альфа",
|
||||||
|
"channelSecret": "Секрет канала",
|
||||||
|
"channelSecretHint": "Общий пароль для этого канала",
|
||||||
|
"channelSecretHelp": "Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу",
|
||||||
|
"channelTypesInfo": "Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.",
|
||||||
|
"hashChannelInfo": "Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.",
|
||||||
|
"channelNameRequired": "Требуется название канала",
|
||||||
|
"channelNameTooLong": "Название канала не должно превышать 31 символ",
|
||||||
|
"channelSecretRequired": "Требуется секрет канала",
|
||||||
|
"channelSecretTooLong": "Секрет канала не должен превышать 32 символа",
|
||||||
|
"invalidAsciiCharacters": "Разрешены только символы ASCII",
|
||||||
|
"channelCreatedSuccessfully": "Канал успешно создан",
|
||||||
|
"channelCreationFailed": "Не удалось создать канал: {error}",
|
||||||
|
"deleteChannel": "Удалить канал",
|
||||||
|
"deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.",
|
||||||
|
"channelDeletedSuccessfully": "Канал успешно удалён",
|
||||||
|
"channelDeletionFailed": "Не удалось удалить канал: {error}",
|
||||||
|
"createChannel": "Создать канал",
|
||||||
|
"wizardBack": "Назад",
|
||||||
|
"wizardSkip": "Пропустить",
|
||||||
|
"wizardNext": "Далее",
|
||||||
|
"wizardGetStarted": "Начать",
|
||||||
|
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
|
||||||
|
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
|
||||||
|
"wizardConnectingTitle": "Подключение к радиоустройству",
|
||||||
|
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
|
||||||
|
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
|
||||||
|
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
|
||||||
|
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
|
||||||
|
"wizardChannelTitle": "Каналы",
|
||||||
|
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
|
||||||
|
"wizardChannelFeature1": "Публичный канал для общей связи команды",
|
||||||
|
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
|
||||||
|
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
|
||||||
|
"wizardContactsTitle": "Контакты",
|
||||||
|
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
|
||||||
|
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
|
||||||
|
"wizardContactsFeature2": "Отправка личных сообщений",
|
||||||
|
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
|
||||||
|
"wizardMapTitle": "Карта и местоположение",
|
||||||
|
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
|
||||||
|
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
|
||||||
|
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
|
||||||
|
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
|
||||||
|
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
|
||||||
|
"viewWelcomeTutorial": "Просмотреть обучение",
|
||||||
|
"allTeamContacts": "Все контакты команды",
|
||||||
|
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
|
||||||
|
"sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам",
|
||||||
|
"noContactsAvailable": "Нет доступных контактов команды",
|
||||||
|
"reply": "Ответить",
|
||||||
|
"technicalDetails": "Технические детали",
|
||||||
|
"messageTechnicalDetails": "Технические детали сообщения",
|
||||||
|
"linkQuality": "Качество связи",
|
||||||
|
"delivery": "Доставка",
|
||||||
|
"status": "Статус",
|
||||||
|
"expectedAckTag": "Ожидаемый тег ACK",
|
||||||
|
"roundTrip": "Время отклика",
|
||||||
|
"retryAttempt": "Попытка повтора",
|
||||||
|
"floodFallback": "Широковещательный резерв",
|
||||||
|
"identity": "Идентификатор",
|
||||||
|
"messageId": "ID сообщения",
|
||||||
|
"sender": "Отправитель",
|
||||||
|
"senderKey": "Ключ отправителя",
|
||||||
|
"recipient": "Получатель",
|
||||||
|
"recipientKey": "Ключ получателя",
|
||||||
|
"voice": "Голос",
|
||||||
|
"voiceId": "ID голоса",
|
||||||
|
"envelope": "Конверт",
|
||||||
|
"sessionProgress": "Прогресс сессии",
|
||||||
|
"complete": "Завершено",
|
||||||
|
"rawDump": "Необработанные данные",
|
||||||
|
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
|
||||||
|
"voiceUnavailable": "Голос сейчас недоступен",
|
||||||
|
"requestingVoice": "Запрос голоса",
|
||||||
|
"device": "устройство",
|
||||||
|
"change": "Изменить",
|
||||||
|
"wizardOverviewDescription": "Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.",
|
||||||
|
"wizardOverviewFeature1": "Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».",
|
||||||
|
"wizardOverviewFeature2": "Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.",
|
||||||
|
"wizardOverviewFeature3": "Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.",
|
||||||
|
"wizardMessagingTitle": "Сообщения и полевые отчёты",
|
||||||
|
"wizardMessagingDescription": "Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.",
|
||||||
|
"wizardMessagingFeature1": "Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.",
|
||||||
|
"wizardMessagingFeature2": "Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.",
|
||||||
|
"wizardMessagingFeature3": "Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.",
|
||||||
|
"wizardConnectDeviceTitle": "Подключить устройство",
|
||||||
|
"wizardConnectDeviceDescription": "Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.",
|
||||||
|
"wizardSetupBadge": "Настройка",
|
||||||
|
"wizardOverviewBadge": "Обзор",
|
||||||
|
"wizardConnectedToDevice": "Подключено к {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Устройство ещё не подключено",
|
||||||
|
"wizardSkipForNow": "Пропустить пока",
|
||||||
|
"wizardDeviceNameLabel": "Имя устройства",
|
||||||
|
"wizardDeviceNameHelp": "Это имя транслируется другим пользователям MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Регион конфигурации",
|
||||||
|
"wizardConfigRegionHelp": "Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Убедитесь, что выбранный профиль соответствует местным радиоправилам.",
|
||||||
|
"wizardPresetNote2": "Список соответствует официальному потоку профилей инструмента MeshCore config.",
|
||||||
|
"wizardPresetNote3": "Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).",
|
||||||
|
"wizardSaving": "Сохранение...",
|
||||||
|
"wizardSaveAndContinue": "Сохранить и продолжить",
|
||||||
|
"wizardEnterDeviceName": "Введите имя устройства перед продолжением.",
|
||||||
|
"wizardDeviceSetupSaved": "Сохранено: {deviceName}, профиль {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Контакты, комнаты и ретрансляторы",
|
||||||
|
"wizardNetworkDescription": "Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.",
|
||||||
|
"wizardNetworkFeature1": "Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.",
|
||||||
|
"wizardNetworkFeature2": "Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.",
|
||||||
|
"wizardNetworkFeature3": "Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.",
|
||||||
|
"wizardMapOpsTitle": "Карта, следы и общая геометрия",
|
||||||
|
"wizardMapOpsDescription": "Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.",
|
||||||
|
"wizardMapOpsFeature1": "Отслеживайте свою позицию, местоположение команды и траектории движения на карте.",
|
||||||
|
"wizardMapOpsFeature2": "Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.",
|
||||||
|
"wizardMapOpsFeature3": "Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.",
|
||||||
|
"wizardToolsTitle": "Инструменты помимо сообщений",
|
||||||
|
"wizardToolsDescription": "Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.",
|
||||||
|
"wizardToolsFeature1": "Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.",
|
||||||
|
"wizardToolsFeature2": "Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.",
|
||||||
|
"wizardToolsFeature3": "Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.",
|
||||||
|
"contactInSensors": "В датчиках",
|
||||||
|
"contactAddToSensors": "Добавить в датчики",
|
||||||
|
"contactSetPath": "Задать путь",
|
||||||
|
"contactAddedToSensors": "{contactName} добавлен в Датчики",
|
||||||
|
"contactFailedToClearRoute": "Не удалось очистить маршрут: {error}",
|
||||||
|
"contactRouteCleared": "Маршрут очищен",
|
||||||
|
"contactRouteSet": "Маршрут задан: {route}",
|
||||||
|
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_tr.arb
Normal file
2332
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_uk.arb
Normal file
2332
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2381
lib/l10n/app_zh.arb
2381
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -17,9 +20,9 @@ import 'providers/app_provider.dart';
|
|||||||
import 'providers/sensors_provider.dart';
|
import 'providers/sensors_provider.dart';
|
||||||
import 'services/voice_codec_service.dart';
|
import 'services/voice_codec_service.dart';
|
||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/tile_cache_service.dart';
|
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
|
import 'services/mesh_map_nodes_service.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
@@ -71,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Shows notification if update is available
|
// Shows notification if update is available
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
|
|
||||||
|
// Refresh remote mesh node cache in background when the app starts.
|
||||||
|
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
@@ -159,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
/// Shows notification if update is available
|
/// Shows notification if update is available
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only check for updates on Android
|
// Only check for updates on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,18 +254,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Image provider (fragment reassembly + outgoing session cache)
|
// Image provider (fragment reassembly + outgoing session cache)
|
||||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||||
|
|
||||||
// Tile cache service
|
|
||||||
Provider(create: (_) => TileCacheService()),
|
|
||||||
|
|
||||||
// App provider that coordinates everything
|
// App provider that coordinates everything
|
||||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||||
ChangeNotifierProxyProvider6<
|
ChangeNotifierProxyProvider5<
|
||||||
ConnectionProvider,
|
ConnectionProvider,
|
||||||
ContactsProvider,
|
ContactsProvider,
|
||||||
MessagesProvider,
|
MessagesProvider,
|
||||||
DrawingProvider,
|
DrawingProvider,
|
||||||
ChannelsProvider,
|
ChannelsProvider,
|
||||||
TileCacheService,
|
|
||||||
AppProvider
|
AppProvider
|
||||||
>(
|
>(
|
||||||
create: (context) => AppProvider(
|
create: (context) => AppProvider(
|
||||||
@@ -270,7 +272,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: context.read<ChannelsProvider>(),
|
channelsProvider: context.read<ChannelsProvider>(),
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
tileCacheService: context.read<TileCacheService>(),
|
|
||||||
),
|
),
|
||||||
update:
|
update:
|
||||||
(
|
(
|
||||||
@@ -280,7 +281,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
messages,
|
messages,
|
||||||
drawings,
|
drawings,
|
||||||
channels,
|
channels,
|
||||||
tileCache,
|
|
||||||
previous,
|
previous,
|
||||||
) =>
|
) =>
|
||||||
previous ??
|
previous ??
|
||||||
@@ -292,7 +292,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: channels,
|
channelsProvider: channels,
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
tileCacheService: tileCache,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -332,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
|
|||||||
@@ -46,12 +46,19 @@ class ContactRouteCodec {
|
|||||||
static const int maxPathBytes = 64;
|
static const int maxPathBytes = 64;
|
||||||
static const int _unknownDescriptor = 0xFF;
|
static const int _unknownDescriptor = 0xFF;
|
||||||
|
|
||||||
static ParsedContactRoute parse(String input) {
|
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||||
final normalized = input.trim().toUpperCase();
|
final normalized = input.trim().toUpperCase();
|
||||||
if (normalized.isEmpty) {
|
if (normalized.isEmpty) {
|
||||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expectedHashSize != null &&
|
||||||
|
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||||
|
throw const ContactRouteFormatException(
|
||||||
|
'Hash size must be 1, 2, or 3 bytes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final hopTokens = normalized
|
final hopTokens = normalized
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((token) => token.trim())
|
.map((token) => token.trim())
|
||||||
@@ -80,6 +87,13 @@ class ContactRouteCodec {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||||
|
throw ContactRouteFormatException(
|
||||||
|
'Hop "$token" must be $expectedHashSize '
|
||||||
|
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
hashSize ??= currentHashSize;
|
hashSize ??= currentHashSize;
|
||||||
if (hashSize != currentHashSize) {
|
if (hashSize != currentHashSize) {
|
||||||
throw const ContactRouteFormatException(
|
throw const ContactRouteFormatException(
|
||||||
@@ -217,15 +231,14 @@ extension ContactLocalization on Contact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String get routeSummary {
|
String get routeSummary {
|
||||||
if (routeIsUnknown || !routeHasPath) {
|
if (routeIsUnknown) {
|
||||||
return 'Flood/Unknown';
|
return 'Flood/Unknown';
|
||||||
}
|
}
|
||||||
if (routeHopCount == 0) {
|
if (!routeHasPath || routeHopCount == 0) {
|
||||||
return 'Direct';
|
return 'Direct';
|
||||||
}
|
}
|
||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get routeSupportsLegacyRawTransport =>
|
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||||
routeHasPath && routeSignedPathLen >= 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
lib/models/contact_group.dart
Normal file
31
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class SavedContactGroup {
|
||||||
|
final String id;
|
||||||
|
final String sectionKey;
|
||||||
|
final String label;
|
||||||
|
final String query;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const SavedContactGroup({
|
||||||
|
required this.id,
|
||||||
|
required this.sectionKey,
|
||||||
|
required this.label,
|
||||||
|
required this.query,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
SavedContactGroup copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sectionKey,
|
||||||
|
String? label,
|
||||||
|
String? query,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sectionKey: sectionKey ?? this.sectionKey,
|
||||||
|
label: label ?? this.label,
|
||||||
|
query: query ?? this.query,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -10,6 +9,7 @@ enum MapLayerType {
|
|||||||
googleHybrid,
|
googleHybrid,
|
||||||
googleRoadmap,
|
googleRoadmap,
|
||||||
googleTerrain,
|
googleTerrain,
|
||||||
|
// Kept for stored preference compatibility after MBTiles removal.
|
||||||
vectorMbtiles,
|
vectorMbtiles,
|
||||||
wmsBase,
|
wmsBase,
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,6 @@ class MapLayer {
|
|||||||
final String attribution;
|
final String attribution;
|
||||||
final double maxZoom;
|
final double maxZoom;
|
||||||
|
|
||||||
// Vector tile specific properties
|
|
||||||
final bool isVector;
|
|
||||||
final File? mbtilesFile;
|
|
||||||
final String? styleUrl;
|
|
||||||
final String? sourceName;
|
|
||||||
final bool? isGzipped;
|
|
||||||
|
|
||||||
// WMS specific properties
|
// WMS specific properties
|
||||||
final bool isWms;
|
final bool isWms;
|
||||||
final String? wmsBaseUrl;
|
final String? wmsBaseUrl;
|
||||||
@@ -43,11 +36,6 @@ class MapLayer {
|
|||||||
required this.urlTemplate,
|
required this.urlTemplate,
|
||||||
required this.attribution,
|
required this.attribution,
|
||||||
required this.maxZoom,
|
required this.maxZoom,
|
||||||
this.isVector = false,
|
|
||||||
this.mbtilesFile,
|
|
||||||
this.styleUrl,
|
|
||||||
this.sourceName,
|
|
||||||
this.isGzipped,
|
|
||||||
this.isWms = false,
|
this.isWms = false,
|
||||||
this.wmsBaseUrl,
|
this.wmsBaseUrl,
|
||||||
this.wmsLayers,
|
this.wmsLayers,
|
||||||
@@ -74,7 +62,7 @@ class MapLayer {
|
|||||||
case MapLayerType.googleTerrain:
|
case MapLayerType.googleTerrain:
|
||||||
return localizations.googleTerrain;
|
return localizations.googleTerrain;
|
||||||
case MapLayerType.vectorMbtiles:
|
case MapLayerType.vectorMbtiles:
|
||||||
// For vector tiles, use the name from metadata
|
// Legacy value kept only for preference migration compatibility.
|
||||||
return name;
|
return name;
|
||||||
case MapLayerType.wmsBase:
|
case MapLayerType.wmsBase:
|
||||||
// For WMS layers, use the name (will be localized separately)
|
// For WMS layers, use the name (will be localized separately)
|
||||||
@@ -182,28 +170,4 @@ class MapLayer {
|
|||||||
static MapLayer fromType(MapLayerType type) {
|
static MapLayer fromType(MapLayerType type) {
|
||||||
return allLayers.firstWhere((layer) => layer.type == type);
|
return allLayers.firstWhere((layer) => layer.type == type);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a MapLayer from an MBTiles file
|
|
||||||
static MapLayer fromMbtilesFile({
|
|
||||||
required String name,
|
|
||||||
required File mbtilesFile,
|
|
||||||
required String styleUrl,
|
|
||||||
required String sourceName,
|
|
||||||
required double maxZoom,
|
|
||||||
required bool isGzipped,
|
|
||||||
String? attribution,
|
|
||||||
}) {
|
|
||||||
return MapLayer(
|
|
||||||
type: MapLayerType.vectorMbtiles,
|
|
||||||
name: name,
|
|
||||||
urlTemplate: '', // Not used for vector tiles
|
|
||||||
attribution: attribution ?? 'MBTiles',
|
|
||||||
maxZoom: maxZoom,
|
|
||||||
isVector: true,
|
|
||||||
mbtilesFile: mbtilesFile,
|
|
||||||
styleUrl: styleUrl,
|
|
||||||
sourceName: sourceName,
|
|
||||||
isGzipped: isGzipped,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -175,13 +175,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
||||||
onMessageSent;
|
onMessageSent;
|
||||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
||||||
|
Future<Contact?> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required int retryAttempt,
|
||||||
|
})?
|
||||||
|
prepareDirectMessageSendCallback;
|
||||||
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
||||||
|
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||||
|
bool Function()? canStartAutomaticMessageSyncCallback;
|
||||||
|
|
||||||
// Track pending send operations for auto-recovery
|
// Track pending send operations for auto-recovery
|
||||||
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
|
||||||
|
bool _pendingAutomaticMessageSync = false;
|
||||||
|
|
||||||
ConnectionProvider() {
|
ConnectionProvider() {
|
||||||
_wireServiceCallbacks(_bleService);
|
_wireServiceCallbacks(_bleService);
|
||||||
@@ -326,6 +335,13 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
|
_pendingAutomaticMessageSync = true;
|
||||||
|
debugPrint(
|
||||||
|
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -480,6 +496,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Start scanning for MeshCore devices
|
/// Start scanning for MeshCore devices
|
||||||
Future<void> startScan() async {
|
Future<void> startScan() async {
|
||||||
debugPrint('🔍 [Provider] startScan() called');
|
debugPrint('🔍 [Provider] startScan() called');
|
||||||
|
if (_deviceInfo.connectionState == ConnectionState.connecting ||
|
||||||
|
_deviceInfo.connectionState == ConnectionState.connected) {
|
||||||
|
debugPrint(
|
||||||
|
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isScanning = true;
|
_isScanning = true;
|
||||||
_scannedDevices.clear();
|
_scannedDevices.clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -553,6 +577,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_isScanning) {
|
||||||
|
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
||||||
|
await stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
deviceId: device.remoteId.toString(),
|
deviceId: device.remoteId.toString(),
|
||||||
deviceName: device.platformName.isNotEmpty
|
deviceName: device.platformName.isNotEmpty
|
||||||
@@ -829,10 +858,26 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
|
||||||
|
if (_isEmptyChannelQueryError(e)) {
|
||||||
|
debugPrint(
|
||||||
|
' ℹ️ Treating slot $channelIdx as empty because the device reported it was not found',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isEmptyChannelQueryError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains('not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isChannelRefreshTimeoutError(Object error) {
|
||||||
|
final message = error.toString().toLowerCase();
|
||||||
|
return message.contains('0x1f') && message.contains('timed out');
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> findNextEmptyChannelSlot() async {
|
Future<int?> findNextEmptyChannelSlot() async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
throw Exception('Not connected to device');
|
throw Exception('Not connected to device');
|
||||||
@@ -843,6 +888,35 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// maxChannels from device info, or default to 40
|
// maxChannels from device info, or default to 40
|
||||||
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
|
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
||||||
|
|
||||||
|
// Match meshcore-open: trust the current synced channel list first and
|
||||||
|
// pick the first missing slot rather than probing every slot on-device.
|
||||||
|
if (getChannelInfo != null) {
|
||||||
|
final usedIndices = <int>{};
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
final channel = getChannelInfo!(i);
|
||||||
|
if (channel == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final channelName = (channel as dynamic).name as String?;
|
||||||
|
if (channelName != null && channelName.isNotEmpty) {
|
||||||
|
usedIndices.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
|
if (!usedIndices.contains(i)) {
|
||||||
|
debugPrint(' ✅ Found empty slot from synced channels: $i');
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check each slot starting from 1 (skip 0 = public channel)
|
// Check each slot starting from 1 (skip 0 = public channel)
|
||||||
for (int i = 1; i < maxChannels; i++) {
|
for (int i = 1; i < maxChannels; i++) {
|
||||||
@@ -939,10 +1013,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
|
||||||
} else {
|
} else {
|
||||||
// Find next empty slot for new channel
|
// Find next empty slot for new channel
|
||||||
|
final maxChannels = _deviceInfo.maxChannels ?? 40;
|
||||||
|
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
|
||||||
final emptySlot = await findNextEmptyChannelSlot();
|
final emptySlot = await findNextEmptyChannelSlot();
|
||||||
if (emptySlot == null) {
|
if (emptySlot == null) {
|
||||||
throw Exception(
|
throw Exception(
|
||||||
'All channel slots are in use (maximum 39 custom channels)',
|
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
slotIdx = emptySlot;
|
slotIdx = emptySlot;
|
||||||
@@ -963,23 +1039,33 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
debugPrint(' Secret converted to 16-byte key using MD5');
|
debugPrint(' Secret converted to 16-byte key using MD5');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send CMD_SET_CHANNEL to radio
|
// Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
|
||||||
|
// reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
|
||||||
|
try {
|
||||||
await _activeService.setChannel(
|
await _activeService.setChannel(
|
||||||
channelIdx: slotIdx,
|
channelIdx: slotIdx,
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
secret: secretBytes,
|
secret: secretBytes,
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (_isChannelRefreshTimeoutError(e)) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
|
||||||
|
);
|
||||||
|
onChannelInfoReceived?.call(
|
||||||
|
slotIdx,
|
||||||
|
channelName,
|
||||||
|
Uint8List.fromList(secretBytes),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
|
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Small delay to allow the response to propagate
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
|
|
||||||
// Refresh channels to update UI
|
|
||||||
// The channel info will be received via onChannelInfoReceived callback
|
|
||||||
await _activeService.getChannel(slotIdx);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to create channel: $e';
|
_error = 'Failed to create channel: $e';
|
||||||
debugPrint('❌ [Provider] Channel creation failed: $e');
|
debugPrint('❌ [Provider] Channel creation failed: $e');
|
||||||
@@ -1073,7 +1159,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// [messageId] - optional message ID to track delivery status
|
/// [messageId] - optional message ID to track delivery status
|
||||||
/// [contact] - optional contact object for path status logging
|
/// [contact] - optional contact object for path status logging
|
||||||
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
|
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
|
||||||
Future<bool> sendTextMessage({
|
Future<bool> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required String text,
|
required String text,
|
||||||
@@ -1087,6 +1173,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var effectiveContact = contact;
|
||||||
|
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
|
||||||
|
if (messageId != null &&
|
||||||
|
effectiveContact != null &&
|
||||||
|
prepareDirectMessageSendCallback != null) {
|
||||||
|
effectiveContact = await prepareDirectMessageSendCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: effectiveContact,
|
||||||
|
retryAttempt: retryAttempt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
||||||
// Rate limit at 7 to stay under the limit
|
// Rate limit at 7 to stay under the limit
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
@@ -1111,33 +1209,33 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Log path status and retry info
|
// Log path status and retry info
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
debugPrint(' Type: ${contact.type.displayName}');
|
debugPrint(' Type: ${effectiveContact.type.displayName}');
|
||||||
debugPrint(' Path status: ${contact.routeSummary}');
|
debugPrint(' Path status: ${effectiveContact.routeSummary}');
|
||||||
if (contact.routeHasPath) {
|
if (effectiveContact.routeHasPath) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
|
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||||
}
|
}
|
||||||
} else if (retryAttempt > 0) {
|
} else if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pending operation for auto-recovery (if contact not found in radio)
|
// Track pending operation for auto-recovery (if contact not found in radio)
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -1146,7 +1244,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
contactPublicKey: contactPublicKey,
|
contactPublicKey: contactPublicKey,
|
||||||
text: text,
|
text: text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: effectiveContact,
|
||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1191,7 +1289,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Clear pending operation after successful send (no error)
|
// Clear pending operation after successful send (no error)
|
||||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -1290,6 +1388,19 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a raw private zero-hop payload.
|
||||||
|
///
|
||||||
|
/// This wraps the raw custom transport using an empty path to match the
|
||||||
|
/// firmware's private multicast behavior.
|
||||||
|
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
|
||||||
|
if (!_activeService.isConnected) return;
|
||||||
|
await _activeService.sendRawVoicePacket(
|
||||||
|
contactPathLen: 0,
|
||||||
|
contactPath: Uint8List(0),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Request telemetry from contact
|
/// Request telemetry from contact
|
||||||
///
|
///
|
||||||
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
||||||
@@ -1792,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sync all waiting messages from device
|
/// Sync all waiting messages from device
|
||||||
Future<int> syncAllMessages() async {
|
Future<int> syncAllMessages({bool force = false}) async {
|
||||||
if (_isSpectrumScanActive) {
|
if (_isSpectrumScanActive) {
|
||||||
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
|
_pendingAutomaticMessageSync = true;
|
||||||
|
debugPrint(
|
||||||
|
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Already syncing; avoid overlapping loops
|
// Already syncing; avoid overlapping loops
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -1812,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
int totalCount = 0;
|
int totalCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_pendingAutomaticMessageSync = false;
|
||||||
_isSyncingMessages = true;
|
_isSyncingMessages = true;
|
||||||
do {
|
do {
|
||||||
_syncRequestedWhileBusy = false;
|
_syncRequestedWhileBusy = false;
|
||||||
@@ -1903,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
|
||||||
|
|
||||||
|
void clearPendingAutomaticMessageSync() {
|
||||||
|
_pendingAutomaticMessageSync = false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Login to a room or repeater
|
/// Login to a room or repeater
|
||||||
///
|
///
|
||||||
/// Sends login request with password. Results will be delivered via
|
/// Sends login request with password. Results will be delivered via
|
||||||
@@ -2015,6 +2140,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
await _activeService.resetPath(contactPublicKey);
|
await _activeService.resetPath(contactPublicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to reset path: $e';
|
_error = 'Failed to reset path: $e';
|
||||||
@@ -2034,6 +2160,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
final updatedContact = contact.copyWith(
|
final updatedContact = contact.copyWith(
|
||||||
outPathLen: signedEncodedPathLen,
|
outPathLen: signedEncodedPathLen,
|
||||||
outPath: Uint8List.fromList(paddedPathBytes),
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
|
import '../utils/fast_gps_packet.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
|
||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
final Uint8List publicKey;
|
final Uint8List publicKey;
|
||||||
final DateTime receivedAt;
|
final DateTime receivedAt;
|
||||||
|
final int? signedEncodedPathLen;
|
||||||
|
final Uint8List? paddedPathBytes;
|
||||||
|
|
||||||
const PendingAdvert({required this.publicKey, required this.receivedAt});
|
const PendingAdvert({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.receivedAt,
|
||||||
|
this.signedEncodedPathLen,
|
||||||
|
this.paddedPathBytes,
|
||||||
|
});
|
||||||
|
|
||||||
String get publicKeyHex =>
|
String get publicKeyHex =>
|
||||||
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||||
@@ -20,17 +30,36 @@ class PendingAdvert {
|
|||||||
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) {
|
PendingAdvert copyWith({
|
||||||
|
Uint8List? publicKey,
|
||||||
|
DateTime? receivedAt,
|
||||||
|
int? signedEncodedPathLen,
|
||||||
|
Uint8List? paddedPathBytes,
|
||||||
|
}) {
|
||||||
return PendingAdvert(
|
return PendingAdvert(
|
||||||
publicKey: publicKey ?? this.publicKey,
|
publicKey: publicKey ?? this.publicKey,
|
||||||
receivedAt: receivedAt ?? this.receivedAt,
|
receivedAt: receivedAt ?? this.receivedAt,
|
||||||
|
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
|
||||||
|
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RetainedRoute {
|
||||||
|
final int signedEncodedPathLen;
|
||||||
|
final Uint8List paddedPathBytes;
|
||||||
|
|
||||||
|
const _RetainedRoute({
|
||||||
|
required this.signedEncodedPathLen,
|
||||||
|
required this.paddedPathBytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Contacts Provider - manages contact list and telemetry
|
/// Contacts Provider - manages contact list and telemetry
|
||||||
class ContactsProvider with ChangeNotifier {
|
class ContactsProvider with ChangeNotifier {
|
||||||
|
static const double _firstHopFallbackOffsetMeters = 100.0;
|
||||||
final Map<String, Contact> _contacts = {};
|
final Map<String, Contact> _contacts = {};
|
||||||
|
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
|
||||||
final Map<String, PendingAdvert> _pendingAdverts = {};
|
final Map<String, PendingAdvert> _pendingAdverts = {};
|
||||||
final ContactStorageService _storageService = ContactStorageService();
|
final ContactStorageService _storageService = ContactStorageService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
@@ -52,6 +81,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
|
||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -65,8 +95,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
|
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure public channel exists after loading
|
// Ensure public channel exists after loading
|
||||||
@@ -96,6 +129,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
final storedContacts = await _storageService.loadContacts(
|
final storedContacts = await _storageService.loadContacts(
|
||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -109,8 +143,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
_savedContactGroups
|
||||||
|
..clear()
|
||||||
|
..addAll(storedGroups);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ensure public channel exists after loading
|
// Ensure public channel exists after loading
|
||||||
@@ -181,10 +218,97 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> get contacts => _contacts.values.toList();
|
List<Contact> get contacts => _contacts.values.toList();
|
||||||
|
List<SavedContactGroup> get savedContactGroups =>
|
||||||
|
List<SavedContactGroup>.from(_savedContactGroups)
|
||||||
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
List<PendingAdvert> get pendingAdverts =>
|
List<PendingAdvert> get pendingAdverts =>
|
||||||
_pendingAdverts.values.toList()
|
_pendingAdverts.values.toList()
|
||||||
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||||
|
|
||||||
|
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
|
||||||
|
return savedContactGroups
|
||||||
|
.where((group) => group.sectionKey == sectionKey)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasSavedGroupForFilter(String sectionKey, String query) {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _savedContactGroups.any(
|
||||||
|
(group) =>
|
||||||
|
group.sectionKey == sectionKey &&
|
||||||
|
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSavedGroupForFilter(
|
||||||
|
String sectionKey,
|
||||||
|
String query, {
|
||||||
|
String? label,
|
||||||
|
}) async {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
if (normalizedQuery.isEmpty ||
|
||||||
|
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_savedContactGroups.add(
|
||||||
|
SavedContactGroup(
|
||||||
|
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
sectionKey: sectionKey,
|
||||||
|
label: (label ?? query).trim(),
|
||||||
|
query: query.trim(),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSavedGroupById(String id) async {
|
||||||
|
final beforeCount = _savedContactGroups.length;
|
||||||
|
_savedContactGroups.removeWhere((group) => group.id == id);
|
||||||
|
if (_savedContactGroups.length == beforeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSavedGroupForFilter(
|
||||||
|
String sectionKey,
|
||||||
|
String query,
|
||||||
|
) async {
|
||||||
|
final normalizedQuery = _normalizeGroupQuery(query);
|
||||||
|
final beforeCount = _savedContactGroups.length;
|
||||||
|
_savedContactGroups.removeWhere(
|
||||||
|
(group) =>
|
||||||
|
group.sectionKey == sectionKey &&
|
||||||
|
_normalizeGroupQuery(group.query) == normalizedQuery,
|
||||||
|
);
|
||||||
|
if (_savedContactGroups.length == beforeCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _persistSavedGroups();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistSavedGroups() async {
|
||||||
|
try {
|
||||||
|
await _storageService.saveContactGroups(_savedContactGroups);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
|
||||||
|
|
||||||
List<Contact> get chatContacts =>
|
List<Contact> get chatContacts =>
|
||||||
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
|
||||||
|
|
||||||
@@ -282,45 +406,16 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a new contact
|
// Check if this is a new contact
|
||||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
final isNewContact = existingContact == null;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||||||
);
|
);
|
||||||
|
|
||||||
Contact updatedContact;
|
final updatedContact = _mergeIncomingContact(
|
||||||
if (isNewContact) {
|
incomingContact: contact,
|
||||||
// New contact - add initial location to history if available
|
existingContact: existingContact,
|
||||||
updatedContact = contact.copyWith(isNew: true);
|
|
||||||
if (contact.advertLocation != null) {
|
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
contact.lastAdvert * 1000,
|
|
||||||
);
|
);
|
||||||
updatedContact = updatedContact.addAdvertLocation(
|
|
||||||
contact.advertLocation!,
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Existing contact - preserve history and isNew status
|
|
||||||
final existingContact = _contacts[contact.publicKeyHex]!;
|
|
||||||
|
|
||||||
// Start with existing contact
|
|
||||||
updatedContact = contact.copyWith(
|
|
||||||
isNew: existingContact.isNew,
|
|
||||||
advertHistory: existingContact.advertHistory,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add new location to history if location has changed
|
|
||||||
if (contact.advertLocation != null) {
|
|
||||||
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
contact.lastAdvert * 1000,
|
|
||||||
);
|
|
||||||
updatedContact = updatedContact.addAdvertLocation(
|
|
||||||
contact.advertLocation!,
|
|
||||||
timestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
@@ -346,7 +441,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excluded++;
|
excluded++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_contacts[contact.publicKeyHex] = contact;
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
||||||
|
incomingContact: contact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
);
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
if (excluded > 0) {
|
if (excluded > 0) {
|
||||||
@@ -358,6 +457,232 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Contact _mergeIncomingContact({
|
||||||
|
required Contact incomingContact,
|
||||||
|
Contact? existingContact,
|
||||||
|
}) {
|
||||||
|
final retainedRoute = _retainedRouteForContact(
|
||||||
|
keyHex: incomingContact.publicKeyHex,
|
||||||
|
incomingContact: incomingContact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
);
|
||||||
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: existingContact?.telemetry,
|
||||||
|
incomingTelemetry: incomingContact.telemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
|
||||||
|
incomingContact: incomingContact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
retainedRoute: retainedRoute,
|
||||||
|
mergedTelemetry: mergedTelemetry,
|
||||||
|
);
|
||||||
|
final inferredFallbackAdvLat = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
|
||||||
|
: null;
|
||||||
|
final inferredFallbackAdvLon = inferredFallbackLocation != null
|
||||||
|
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (existingContact == null) {
|
||||||
|
var newContact = incomingContact.copyWith(
|
||||||
|
isNew: true,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
|
outPathLen:
|
||||||
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
|
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||||||
|
advLat: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLat
|
||||||
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
|
advLon: incomingContact.advertLocation != null
|
||||||
|
? incomingContact.advLon
|
||||||
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
|
);
|
||||||
|
if (incomingContact.advertLocation != null) {
|
||||||
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
incomingContact.lastAdvert * 1000,
|
||||||
|
);
|
||||||
|
newContact = newContact.addAdvertLocation(
|
||||||
|
incomingContact.advertLocation!,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
final incomingAdvertLocation = incomingContact.advertLocation;
|
||||||
|
final existingAdvertLocation = existingContact.advertLocation;
|
||||||
|
|
||||||
|
var updatedContact = incomingContact.copyWith(
|
||||||
|
isNew: false,
|
||||||
|
advertHistory: existingContact.advertHistory,
|
||||||
|
telemetry: mergedTelemetry,
|
||||||
|
outPathLen:
|
||||||
|
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
|
||||||
|
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
|
||||||
|
advLat: incomingAdvertLocation != null
|
||||||
|
? incomingContact.advLat
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLat
|
||||||
|
: inferredFallbackAdvLat ?? incomingContact.advLat,
|
||||||
|
advLon: incomingAdvertLocation != null
|
||||||
|
? incomingContact.advLon
|
||||||
|
: existingAdvertLocation != null
|
||||||
|
? existingContact.advLon
|
||||||
|
: inferredFallbackAdvLon ?? incomingContact.advLon,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (incomingAdvertLocation != null) {
|
||||||
|
final timestamp = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
incomingContact.lastAdvert * 1000,
|
||||||
|
);
|
||||||
|
updatedContact = updatedContact.addAdvertLocation(
|
||||||
|
incomingAdvertLocation,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
_RetainedRoute? _retainedRouteForContact({
|
||||||
|
required String keyHex,
|
||||||
|
required Contact incomingContact,
|
||||||
|
required Contact? existingContact,
|
||||||
|
}) {
|
||||||
|
if (incomingContact.routeHasPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingAdvert = _pendingAdverts[keyHex];
|
||||||
|
final pendingPathBytes = pendingAdvert?.paddedPathBytes;
|
||||||
|
final pendingPathLen = pendingAdvert?.signedEncodedPathLen;
|
||||||
|
if (pendingPathLen != null &&
|
||||||
|
pendingPathBytes != null &&
|
||||||
|
pendingPathBytes.isNotEmpty) {
|
||||||
|
return _RetainedRoute(
|
||||||
|
signedEncodedPathLen: pendingPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(pendingPathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingContact != null && existingContact.routeHasPath) {
|
||||||
|
return _RetainedRoute(
|
||||||
|
signedEncodedPathLen: existingContact.outPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(existingContact.outPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? _inferFirstHopFallbackLocation({
|
||||||
|
required Contact incomingContact,
|
||||||
|
required Contact? existingContact,
|
||||||
|
required _RetainedRoute? retainedRoute,
|
||||||
|
required ContactTelemetry? mergedTelemetry,
|
||||||
|
}) {
|
||||||
|
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (incomingContact.advertLocation != null ||
|
||||||
|
existingContact?.advertLocation != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final routeBytes =
|
||||||
|
retainedRoute?.paddedPathBytes ??
|
||||||
|
(incomingContact.routeHasPath
|
||||||
|
? incomingContact.routePathBytes
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routePathBytes
|
||||||
|
: null);
|
||||||
|
final routeHashSize = retainedRoute != null
|
||||||
|
? ((ContactRouteCodec.toUnsignedDescriptor(
|
||||||
|
retainedRoute.signedEncodedPathLen,
|
||||||
|
) >>
|
||||||
|
6) +
|
||||||
|
1)
|
||||||
|
: incomingContact.routeHasPath
|
||||||
|
? incomingContact.routeHashSize
|
||||||
|
: existingContact?.routeHasPath == true
|
||||||
|
? existingContact!.routeHashSize
|
||||||
|
: 0;
|
||||||
|
if (routeBytes == null ||
|
||||||
|
routeHashSize <= 0 ||
|
||||||
|
routeBytes.length < routeHashSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastHopHex = routeBytes
|
||||||
|
.sublist(routeBytes.length - routeHashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final repeaterCandidates = _contacts.values.where((candidate) {
|
||||||
|
if (!candidate.isRepeater ||
|
||||||
|
candidate.publicKeyHex == incomingContact.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return location != null && candidate.publicKeyHex.startsWith(lastHopHex);
|
||||||
|
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
|
||||||
|
if (repeaterCandidates.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaterLocation = repeaterCandidates.first.displayLocation;
|
||||||
|
if (repeaterLocation == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
|
||||||
|
return _offsetFromLocation(
|
||||||
|
repeaterLocation,
|
||||||
|
distanceMeters: _firstHopFallbackOffsetMeters,
|
||||||
|
bearingDegrees: bearingDegrees,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _stableFallbackBearingDegrees(Contact contact) {
|
||||||
|
if (contact.publicKey.length < 2) {
|
||||||
|
return 90.0;
|
||||||
|
}
|
||||||
|
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
|
||||||
|
return (seed % 360).toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng _offsetFromLocation(
|
||||||
|
LatLng origin, {
|
||||||
|
required double distanceMeters,
|
||||||
|
required double bearingDegrees,
|
||||||
|
}) {
|
||||||
|
const earthRadiusMeters = 6371000.0;
|
||||||
|
final angularDistance = distanceMeters / earthRadiusMeters;
|
||||||
|
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
|
||||||
|
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
|
||||||
|
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
|
||||||
|
|
||||||
|
final sinLat1 = sin(lat1);
|
||||||
|
final cosLat1 = cos(lat1);
|
||||||
|
final sinAngularDistance = sin(angularDistance);
|
||||||
|
final cosAngularDistance = cos(angularDistance);
|
||||||
|
|
||||||
|
final lat2 = asin(
|
||||||
|
sinLat1 * cosAngularDistance +
|
||||||
|
cosLat1 * sinAngularDistance * cos(bearingRadians),
|
||||||
|
);
|
||||||
|
final lon2 =
|
||||||
|
lon1 +
|
||||||
|
atan2(
|
||||||
|
sin(bearingRadians) * sinAngularDistance * cosLat1,
|
||||||
|
cosAngularDistance - sinLat1 * sin(lat2),
|
||||||
|
);
|
||||||
|
|
||||||
|
return LatLng(
|
||||||
|
lat2 * 180.0 / 3.1415926535897932,
|
||||||
|
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Update contact telemetry
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
@@ -397,23 +722,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep last valid GPS for router/chat/room contacts when current
|
final previousTelemetry = contact.telemetry;
|
||||||
// telemetry does not provide a valid GPS fix.
|
|
||||||
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) {
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: previousTelemetry,
|
||||||
|
incomingTelemetry: telemetry,
|
||||||
|
);
|
||||||
|
if (mergedTelemetry != null) {
|
||||||
|
if (_shouldRetainLastValidGps(
|
||||||
|
previousTelemetry,
|
||||||
|
telemetry.gpsLocation,
|
||||||
|
)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
||||||
);
|
);
|
||||||
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
}
|
||||||
telemetry = ContactTelemetry(
|
telemetry = mergedTelemetry;
|
||||||
gpsLocation: previousGps,
|
|
||||||
batteryPercentage: telemetry.batteryPercentage,
|
|
||||||
batteryMilliVolts: telemetry.batteryMilliVolts,
|
|
||||||
temperature: telemetry.temperature,
|
|
||||||
timestamp: telemetry.timestamp,
|
|
||||||
humidity: telemetry.humidity,
|
|
||||||
pressure: telemetry.pressure,
|
|
||||||
extraSensorData: telemetry.extraSensorData,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update contact with new telemetry AND last seen time
|
// Update contact with new telemetry AND last seen time
|
||||||
@@ -448,6 +772,42 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) {
|
||||||
|
final contact = _findContactByPrefix(publicKeyPrefix);
|
||||||
|
if (contact == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedTelemetry = _mergeTelemetryForContact(
|
||||||
|
existingTelemetry: contact.telemetry,
|
||||||
|
incomingTelemetry: ContactTelemetry(
|
||||||
|
gpsLocation: LatLng(packet.latitude, packet.longitude),
|
||||||
|
batteryPercentage: null,
|
||||||
|
batteryMilliVolts: null,
|
||||||
|
temperature: null,
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
packet.timestampSeconds * 1000,
|
||||||
|
),
|
||||||
|
humidity: null,
|
||||||
|
pressure: null,
|
||||||
|
extraSensorData: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedContact = contact.copyWith(
|
||||||
|
telemetry: updatedTelemetry,
|
||||||
|
lastAdvert: packet.timestampSeconds,
|
||||||
|
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
|
||||||
|
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
|
||||||
|
);
|
||||||
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool _isInvalidTelemetryGps(LatLng? location) {
|
bool _isInvalidTelemetryGps(LatLng? location) {
|
||||||
if (location == null) return false;
|
if (location == null) return false;
|
||||||
final lat = location.latitude;
|
final lat = location.latitude;
|
||||||
@@ -467,15 +827,12 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) {
|
bool _shouldRetainLastValidGps(
|
||||||
final isSupportedType =
|
ContactTelemetry? existingTelemetry,
|
||||||
contact.isChat || contact.isRepeater || contact.isRoom;
|
LatLng? incomingGps,
|
||||||
if (!isSupportedType) {
|
) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final hasPreviousValidGps =
|
final hasPreviousValidGps =
|
||||||
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null;
|
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
|
||||||
if (!hasPreviousValidGps) {
|
if (!hasPreviousValidGps) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -483,6 +840,42 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return incomingGps == null;
|
return incomingGps == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ContactTelemetry? _mergeTelemetryForContact({
|
||||||
|
ContactTelemetry? existingTelemetry,
|
||||||
|
ContactTelemetry? incomingTelemetry,
|
||||||
|
}) {
|
||||||
|
if (incomingTelemetry == null) {
|
||||||
|
return existingTelemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telemetry packets and contact refreshes are often sparse. Preserve the
|
||||||
|
// last known reading for any field that is omitted in the incoming update.
|
||||||
|
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
|
||||||
|
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
|
||||||
|
final mergedExtraSensorData = <String, dynamic>{
|
||||||
|
...?existingTelemetry?.extraSensorData,
|
||||||
|
...?incomingTelemetry.extraSensorData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ContactTelemetry(
|
||||||
|
gpsLocation: incomingGps ?? previousGps,
|
||||||
|
batteryPercentage:
|
||||||
|
incomingTelemetry.batteryPercentage ??
|
||||||
|
existingTelemetry?.batteryPercentage,
|
||||||
|
batteryMilliVolts:
|
||||||
|
incomingTelemetry.batteryMilliVolts ??
|
||||||
|
existingTelemetry?.batteryMilliVolts,
|
||||||
|
temperature:
|
||||||
|
incomingTelemetry.temperature ?? existingTelemetry?.temperature,
|
||||||
|
timestamp: incomingTelemetry.timestamp,
|
||||||
|
humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity,
|
||||||
|
pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure,
|
||||||
|
extraSensorData: mergedExtraSensorData.isEmpty
|
||||||
|
? null
|
||||||
|
: mergedExtraSensorData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
int _coordinateToAdvertMicrodegrees(double coordinate) {
|
||||||
return (coordinate * 1e6).round();
|
return (coordinate * 1e6).round();
|
||||||
}
|
}
|
||||||
@@ -548,18 +941,72 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
Uint8List publicKey, {
|
Uint8List publicKey, {
|
||||||
required int signedEncodedPathLen,
|
required int signedEncodedPathLen,
|
||||||
required Uint8List paddedPathBytes,
|
required Uint8List paddedPathBytes,
|
||||||
|
LatLng? inferredFallbackLocation,
|
||||||
}) {
|
}) {
|
||||||
final contact = findContactByKey(publicKey);
|
final contact = findContactByKey(publicKey);
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
var updatedContact = contact.copyWith(
|
||||||
|
outPathLen: signedEncodedPathLen,
|
||||||
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
|
);
|
||||||
|
if (inferredFallbackLocation != null) {
|
||||||
|
updatedContact = updatedContact
|
||||||
|
.copyWith(
|
||||||
|
advLat: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.latitude,
|
||||||
|
),
|
||||||
|
advLon: _coordinateToAdvertMicrodegrees(
|
||||||
|
inferredFallbackLocation.longitude,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
|
||||||
|
}
|
||||||
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
|
_persistContacts();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void retainReceivedRoute(
|
||||||
|
Uint8List publicKey, {
|
||||||
|
required int signedEncodedPathLen,
|
||||||
|
required Uint8List paddedPathBytes,
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final keyHex = publicKey
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
final contact = _contacts[keyHex];
|
||||||
|
if (contact != null) {
|
||||||
|
_contacts[keyHex] = contact.copyWith(
|
||||||
outPathLen: signedEncodedPathLen,
|
outPathLen: signedEncodedPathLen,
|
||||||
outPath: Uint8List.fromList(paddedPathBytes),
|
outPath: Uint8List.fromList(paddedPathBytes),
|
||||||
);
|
);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final existing = _pendingAdverts[keyHex];
|
||||||
|
_pendingAdverts[keyHex] =
|
||||||
|
(existing ??
|
||||||
|
PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
|
receivedAt: now,
|
||||||
|
))
|
||||||
|
.copyWith(
|
||||||
|
receivedAt: now,
|
||||||
|
signedEncodedPathLen: signedEncodedPathLen,
|
||||||
|
paddedPathBytes: Uint8List.fromList(paddedPathBytes),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void resetContactRouteLocal(Uint8List publicKey) {
|
void resetContactRouteLocal(Uint8List publicKey) {
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import '../../models/contact.dart';
|
|||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||||
/// - Firmware does NOT automatically retry - app must implement
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
@@ -21,10 +19,10 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
static const int maxRetryAttempts = 4;
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -32,13 +30,12 @@ class MessageRetryManager {
|
|||||||
static const int _defaultLoRaCrcEnabled = 1;
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
static const int _defaultLoRaExplicitHeader = 1;
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get backoff delay for the next retry attempt.
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
int getDelayForAttempt(int attempt) {
|
||||||
int getTimeoutForAttempt(int attempt) {
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
return _retryDelays.last;
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||||
@@ -65,43 +62,8 @@ class MessageRetryManager {
|
|||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message is eligible for retry
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - The message has retryAttempt < 3
|
|
||||||
/// - The contact has a learned path (contact.hasPath == true)
|
|
||||||
/// - The message hasn't used flood fallback yet
|
|
||||||
///
|
|
||||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttempts;
|
||||||
if (message.usedFloodFallback) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never retry beyond 3 attempts
|
|
||||||
if (message.retryAttempt >= 3) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only retry if contact has a learned path
|
|
||||||
// If no path, the device uses flood mode automatically - retrying won't help
|
|
||||||
return contact.routeHasPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if should fall back to flood mode
|
|
||||||
///
|
|
||||||
/// Returns true if:
|
|
||||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
|
||||||
/// - Contact HAS a learned path (so direct mode was used)
|
|
||||||
/// - Hasn't already used flood fallback
|
|
||||||
///
|
|
||||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
|
||||||
/// Contacts without paths already use flood mode automatically.
|
|
||||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
|
||||||
return message.retryAttempt >= 3 &&
|
|
||||||
contact.routeHasPath &&
|
|
||||||
!message.usedFloodFallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!requester.routeSupportsLegacyRawTransport) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPath.isEmpty) {
|
if (requester.outPath.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
try {
|
try {
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import '../models/contact.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||||
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||||
|
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -59,6 +62,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Navigation state for message highlighting/scrolling
|
// Navigation state for message highlighting/scrolling
|
||||||
String? _targetMessageId;
|
String? _targetMessageId;
|
||||||
|
String? _targetDestinationType;
|
||||||
|
String? _targetRecipientPublicKeyHex;
|
||||||
|
|
||||||
// Helper function to compare Uint8List for equality
|
// Helper function to compare Uint8List for equality
|
||||||
bool _listEquals(Uint8List a, Uint8List b) {
|
bool _listEquals(Uint8List a, Uint8List b) {
|
||||||
@@ -81,6 +86,26 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalRouterFallbackCallback;
|
||||||
|
Future<void> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalDirectMessageFailureCallback;
|
||||||
|
void Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
required int roundTripTimeMs,
|
||||||
|
})?
|
||||||
|
onDirectMessageDeliveredCallback;
|
||||||
|
|
||||||
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
||||||
String Function(int channelIdx)? resolveChannelNameCallback;
|
String Function(int channelIdx)? resolveChannelNameCallback;
|
||||||
@@ -126,6 +151,34 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
||||||
_messageTransferDetails[messageId];
|
_messageTransferDetails[messageId];
|
||||||
|
|
||||||
|
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||||
|
_messageRouteMetadata[messageId];
|
||||||
|
|
||||||
|
void updateMessageRouteSelection(
|
||||||
|
String messageId,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
|
||||||
|
selection,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
|
||||||
|
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||||
|
if (index != -1) {
|
||||||
|
final nextPathLen = selection.hopCount > 0
|
||||||
|
? selection.hopCount
|
||||||
|
: _messages[index].pathLen;
|
||||||
|
_messages[index] = _messages[index].copyWith(
|
||||||
|
usedFloodFallback: selection.usesFlood,
|
||||||
|
pathLen: nextPathLen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -137,16 +190,99 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? get targetDestinationType => _targetDestinationType;
|
||||||
|
String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex;
|
||||||
|
|
||||||
|
void navigateToDestination(String type, {String? recipientPublicKeyHex}) {
|
||||||
|
_targetDestinationType = type;
|
||||||
|
_targetRecipientPublicKeyHex = recipientPublicKeyHex;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear message navigation state
|
/// Clear message navigation state
|
||||||
void clearMessageNavigation() {
|
void clearMessageNavigation() {
|
||||||
_targetMessageId = null;
|
_targetMessageId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearDestinationNavigation() {
|
||||||
|
_targetDestinationType = null;
|
||||||
|
_targetRecipientPublicKeyHex = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get count of unread messages (excluding sent messages and system messages)
|
/// Get count of unread messages (excluding sent messages and system messages)
|
||||||
int get unreadCount => _messages
|
int get unreadCount => _messages
|
||||||
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
|
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
|
int getMessageCountForDestination(Contact contact) => _messages
|
||||||
|
.where((message) => _isMessageForDestination(message, contact))
|
||||||
|
.length;
|
||||||
|
|
||||||
|
DateTime? getLastActivityForDestination(Contact contact) {
|
||||||
|
DateTime? latest;
|
||||||
|
|
||||||
|
for (final message in _messages) {
|
||||||
|
if (!_isMessageForDestination(message, contact)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest == null || message.sentAt.isAfter(latest)) {
|
||||||
|
latest = message.sentAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
int getUnreadCountForDestination(Contact contact) => _messages
|
||||||
|
.where(
|
||||||
|
(message) =>
|
||||||
|
_isMessageForDestination(message, contact) &&
|
||||||
|
!message.isRead &&
|
||||||
|
!message.isSentMessage &&
|
||||||
|
!message.isSystemMessage,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
bool _isMessageForDestination(Message message, Contact contact) {
|
||||||
|
if (message.isSystemMessage) return false;
|
||||||
|
|
||||||
|
if (contact.isChannel) {
|
||||||
|
final channelIdx = contact.publicKey.length > 1
|
||||||
|
? contact.publicKey[1]
|
||||||
|
: 0;
|
||||||
|
return message.isChannelMessage &&
|
||||||
|
(message.channelIdx ?? 0) == channelIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.isContactMessage || contact.publicKey.length < 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedPrefix = contact.publicKey.sublist(0, 6);
|
||||||
|
|
||||||
|
if (message.recipientPublicKey != null &&
|
||||||
|
message.recipientPublicKey!.length >= 6 &&
|
||||||
|
_matchesPrefix(message.recipientPublicKey!, selectedPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.senderPublicKeyPrefix != null &&
|
||||||
|
message.senderPublicKeyPrefix!.length >= 6 &&
|
||||||
|
_matchesPrefix(message.senderPublicKeyPrefix!, selectedPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesPrefix(Uint8List key, Uint8List prefix) {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
if (key[i] != prefix[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize and load persisted messages
|
/// Initialize and load persisted messages
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
@@ -160,6 +296,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.loadMessageReceptionDetails();
|
.loadMessageReceptionDetails();
|
||||||
final storedTransferDetails = await _storageService
|
final storedTransferDetails = await _storageService
|
||||||
.loadMessageTransferDetails();
|
.loadMessageTransferDetails();
|
||||||
|
final storedRouteMetadata = await _storageService
|
||||||
|
.loadMessageRouteMetadata();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
@@ -169,6 +307,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageTransferDetails
|
_messageTransferDetails
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedTransferDetails);
|
..addAll(storedTransferDetails);
|
||||||
|
_messageRouteMetadata
|
||||||
|
..clear()
|
||||||
|
..addAll(storedRouteMetadata);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -629,9 +770,17 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final idx = channelIdx ?? 0;
|
final idx = channelIdx ?? 0;
|
||||||
final resolved = resolveChannelNameCallback?.call(idx).trim();
|
final resolved = resolveChannelNameCallback?.call(idx).trim();
|
||||||
if (resolved != null && resolved.isNotEmpty) {
|
if (resolved != null && resolved.isNotEmpty) {
|
||||||
|
if (idx == 0 && resolved == 'Public') {
|
||||||
|
return _localizations?.publicChannel ?? resolved;
|
||||||
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
return idx == 0 ? 'Public' : 'Channel $idx';
|
if (idx == 0) {
|
||||||
|
return _localizations?.publicChannel ?? 'Public';
|
||||||
|
}
|
||||||
|
return _localizations != null
|
||||||
|
? '${_localizations!.channel} $idx'
|
||||||
|
: 'Channel $idx';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildNotificationMessageText(
|
String _buildNotificationMessageText(
|
||||||
@@ -645,14 +794,14 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
||||||
final summary =
|
final summary =
|
||||||
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
||||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
final summary =
|
final summary =
|
||||||
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
||||||
return isChannelMessage ? '$senderName\n$summary' : summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isChannelMessage && message.recipientPublicKey != null) {
|
if (!isChannelMessage && message.recipientPublicKey != null) {
|
||||||
@@ -665,10 +814,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isChannelMessage) {
|
|
||||||
return '$senderName\n${message.text}';
|
|
||||||
}
|
|
||||||
|
|
||||||
return message.text;
|
return message.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +833,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
messageContactLocations: _messageContactLocations,
|
messageContactLocations: _messageContactLocations,
|
||||||
messageReceptionDetails: _messageReceptionDetails,
|
messageReceptionDetails: _messageReceptionDetails,
|
||||||
messageTransferDetails: _messageTransferDetails,
|
messageTransferDetails: _messageTransferDetails,
|
||||||
|
messageRouteMetadata: _messageRouteMetadata,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
@@ -768,6 +914,55 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark unread messages for a specific destination as read.
|
||||||
|
void markDestinationAsRead({
|
||||||
|
required String destinationType,
|
||||||
|
Contact? contact,
|
||||||
|
}) {
|
||||||
|
if (destinationType == 'all') {
|
||||||
|
markAllAsRead();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasChanges = false;
|
||||||
|
for (int i = 0; i < _messages.length; i++) {
|
||||||
|
final message = _messages[i];
|
||||||
|
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final matchesDestination = switch (destinationType) {
|
||||||
|
'channel' => _isChannelMessageForContact(message, contact),
|
||||||
|
'contact' ||
|
||||||
|
'room' => contact != null && _isMessageForDestination(message, contact),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!matchesDestination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messages[i] = message.copyWith(isRead: true);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isChannelMessageForContact(Message message, Contact? contact) {
|
||||||
|
if (!message.isChannelMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
|
||||||
|
? contact.publicKey[1]
|
||||||
|
: 0;
|
||||||
|
return (message.channelIdx ?? 0) == selectedChannelIdx;
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark a specific message as read
|
/// Mark a specific message as read
|
||||||
void markAsRead(String messageId) {
|
void markAsRead(String messageId) {
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -806,6 +1001,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.remove(messageId);
|
_messageContactLocations.remove(messageId);
|
||||||
_messageReceptionDetails.remove(messageId);
|
_messageReceptionDetails.remove(messageId);
|
||||||
_messageTransferDetails.remove(messageId);
|
_messageTransferDetails.remove(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -837,6 +1033,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -854,6 +1051,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
_messageTransferDetails.clear();
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1549,6 +1747,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[message.id];
|
final deliveredContact = _messageContactMap[message.id];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: message.id,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: updatedMessage,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1592,6 +1796,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[historicalMessageId];
|
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: historicalMessageId,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: _messages[historicalIndex],
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1677,29 +1887,29 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
||||||
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
||||||
|
|
||||||
// Decision tree for retry/flood/fail
|
final routeMetadata = _messageRouteMetadata[messageId];
|
||||||
|
final routerFallbackAttempted =
|
||||||
|
routeMetadata?.routerFallbackAttempted ?? false;
|
||||||
|
|
||||||
|
// Decision tree for retry/final-router-fallback/fail
|
||||||
if (contact != null && _retryManager.canRetry(message, contact)) {
|
if (contact != null && _retryManager.canRetry(message, contact)) {
|
||||||
// RETRY: Contact has path and retry attempts < 3
|
|
||||||
_scheduleRetry(messageId, message, contact);
|
_scheduleRetry(messageId, message, contact);
|
||||||
} else if (contact != null &&
|
} else if (contact != null && !routerFallbackAttempted) {
|
||||||
_retryManager.shouldUseFloodFallback(message, contact)) {
|
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
|
||||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
|
||||||
_sendWithFloodMode(messageId, message, contact);
|
|
||||||
} else {
|
} else {
|
||||||
// PERMANENTLY FAILED: No retry possible
|
|
||||||
_markAsPermanentlyFailed(messageId, message);
|
_markAsPermanentlyFailed(messageId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule a retry with progressive timeout
|
/// Schedule a retry with exponential backoff.
|
||||||
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
||||||
final nextAttempt = message.retryAttempt + 1;
|
final nextAttempt = message.retryAttempt + 1;
|
||||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
|
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
|
||||||
);
|
);
|
||||||
debugPrint(' Timeout: ${timeout}ms');
|
debugPrint(' Delay: ${delayMs}ms');
|
||||||
|
|
||||||
// Update message with new retry attempt
|
// Update message with new retry attempt
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -1720,10 +1930,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Track retry
|
// Track retry
|
||||||
_retryManager.trackRetry(messageId, nextAttempt);
|
_retryManager.trackRetry(messageId, nextAttempt);
|
||||||
|
|
||||||
notifyListeners(); // Update UI to show "Retrying (X/3)..."
|
notifyListeners();
|
||||||
|
|
||||||
// Schedule actual retry after delay
|
// Schedule actual retry after delay
|
||||||
Timer(Duration(milliseconds: timeout), () async {
|
Timer(Duration(milliseconds: delayMs), () async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||||
);
|
);
|
||||||
@@ -1759,50 +1969,47 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send message with flood mode as last resort
|
Future<void> _sendWithFinalRouterFallback(
|
||||||
Future<void> _sendWithFloodMode(
|
|
||||||
String messageId,
|
String messageId,
|
||||||
Message message,
|
Message message,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
) async {
|
) async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
|
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
|
||||||
);
|
);
|
||||||
|
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_messages[index] = message.copyWith(
|
_messages[index] = message.copyWith(
|
||||||
usedFloodFallback: true,
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel old timeout timer
|
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Send with flood mode (no retry after this)
|
if (onFinalRouterFallbackCallback == null) {
|
||||||
if (sendMessageCallback != null) {
|
debugPrint(
|
||||||
final queued = await sendMessageCallback!(
|
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
|
||||||
contactPublicKey: contact.publicKey,
|
);
|
||||||
text: message.text,
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queued = await onFinalRouterFallbackCallback!(
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
message: _messages[index],
|
||||||
);
|
);
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
|
||||||
);
|
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
}
|
}
|
||||||
@@ -1830,20 +2037,16 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
|
||||||
final failedContact = _messageContactMap[messageId];
|
final failedContact = _messageContactMap[messageId];
|
||||||
if (failedContact != null && failedContact.routeHasPath) {
|
if (failedContact != null &&
|
||||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
onFinalDirectMessageFailureCallback != null) {
|
||||||
debugPrint(
|
|
||||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
|
||||||
);
|
|
||||||
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
|
||||||
unawaited(
|
unawaited(
|
||||||
onDirectPathFailedCallback!(
|
onFinalDirectMessageFailureCallback!(
|
||||||
|
messageId: messageId,
|
||||||
contact: failedContact,
|
contact: failedContact,
|
||||||
failureStreak: failureStreak,
|
message: _messages[index],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1870,6 +2073,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_clearAckHistoryForMessage(messageId);
|
_clearAckHistoryForMessage(messageId);
|
||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
onManualRetryPreparedCallback?.call(messageId);
|
||||||
|
|
||||||
_messages[index] = Message(
|
_messages[index] = Message(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
|
|||||||
@@ -10,36 +10,28 @@ import 'contacts_provider.dart';
|
|||||||
|
|
||||||
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
||||||
|
|
||||||
enum SensorMetric {
|
|
||||||
lastSeen,
|
|
||||||
voltage,
|
|
||||||
battery,
|
|
||||||
temperature,
|
|
||||||
humidity,
|
|
||||||
pressure,
|
|
||||||
gps,
|
|
||||||
updated,
|
|
||||||
}
|
|
||||||
|
|
||||||
class SensorsProvider with ChangeNotifier {
|
class SensorsProvider with ChangeNotifier {
|
||||||
|
static const Duration _successStateRetention = Duration(minutes: 1);
|
||||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
static const Set<SensorMetric> _defaultVisibleMetrics = <SensorMetric>{
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
SensorMetric.lastSeen,
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
SensorMetric.voltage,
|
'voltage',
|
||||||
SensorMetric.battery,
|
'battery',
|
||||||
SensorMetric.temperature,
|
'temperature',
|
||||||
SensorMetric.humidity,
|
'humidity',
|
||||||
SensorMetric.pressure,
|
'pressure',
|
||||||
SensorMetric.gps,
|
'gps',
|
||||||
SensorMetric.updated,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final List<String> _watchedSensorKeys = <String>[];
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
final Map<String, SensorRefreshState> _refreshStates =
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
<String, SensorRefreshState>{};
|
<String, SensorRefreshState>{};
|
||||||
final Map<String, Set<SensorMetric>> _visibleMetricsBySensor =
|
final Map<String, DateTime> _refreshStateUpdatedAt = <String, DateTime>{};
|
||||||
<String, Set<SensorMetric>>{};
|
final Map<String, Set<String>> _visibleFieldsBySensor =
|
||||||
|
<String, Set<String>>{};
|
||||||
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
|
<String, Map<String, int>>{};
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
bool _isRefreshingAll = false;
|
bool _isRefreshingAll = false;
|
||||||
|
|
||||||
@@ -59,25 +51,33 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
||||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
||||||
|
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
||||||
_watchedSensorKeys
|
_watchedSensorKeys
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(stored);
|
..addAll(stored);
|
||||||
_visibleMetricsBySensor.clear();
|
_visibleFieldsBySensor.clear();
|
||||||
|
_fieldSpansBySensor.clear();
|
||||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
final metricNames = (entry.value as List<dynamic>).cast<String>();
|
_visibleFieldsBySensor[entry.key] = (entry.value as List<dynamic>)
|
||||||
_visibleMetricsBySensor[entry.key] = metricNames
|
.cast<String>()
|
||||||
.map(_metricFromName)
|
|
||||||
.whereType<SensorMetric>()
|
|
||||||
.toSet();
|
.toSet();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (storedSpansJson != null && storedSpansJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedSpansJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_fieldSpansBySensor[entry.key] = (entry.value as Map<String, dynamic>)
|
||||||
|
.map((key, value) => MapEntry(key, value as int));
|
||||||
|
}
|
||||||
|
}
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
_visibleMetricsBySensor.putIfAbsent(
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
key,
|
key,
|
||||||
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading watched sensors: $e');
|
debugPrint('Error loading watched sensors: $e');
|
||||||
@@ -100,8 +100,8 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final encoded = <String, List<String>>{};
|
final encoded = <String, List<String>>{};
|
||||||
for (final entry in _visibleMetricsBySensor.entries) {
|
for (final entry in _visibleFieldsBySensor.entries) {
|
||||||
encoded[entry.key] = entry.value.map((metric) => metric.name).toList();
|
encoded[entry.key] = entry.value.toList();
|
||||||
}
|
}
|
||||||
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -109,35 +109,63 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<SensorMetric> visibleMetricsFor(String publicKeyHex) =>
|
Future<void> _persistFieldSpans() async {
|
||||||
Set<SensorMetric>.unmodifiable(
|
try {
|
||||||
_visibleMetricsBySensor[publicKeyHex] ?? _defaultVisibleMetrics,
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor field spans: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
);
|
);
|
||||||
|
|
||||||
bool showsMetric(String publicKeyHex, SensorMetric metric) =>
|
bool showsField(String publicKeyHex, String fieldKey) =>
|
||||||
visibleMetricsFor(publicKeyHex).contains(metric);
|
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||||
|
|
||||||
|
int fieldSpanFor(String publicKeyHex, String fieldKey) {
|
||||||
|
final sensorSpans = _fieldSpansBySensor[publicKeyHex];
|
||||||
|
final span = sensorSpans?[fieldKey] ?? 1;
|
||||||
|
return span == 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleMetric(
|
Future<void> toggleMetric(
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
SensorMetric metric,
|
String fieldKey,
|
||||||
bool visible,
|
bool visible,
|
||||||
) async {
|
) async {
|
||||||
final visibleMetrics = _visibleMetricsBySensor.putIfAbsent(
|
final visibleFields = _visibleFieldsBySensor.putIfAbsent(
|
||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
() => Set<SensorMetric>.from(_defaultVisibleMetrics),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
if (visible) {
|
if (visible) {
|
||||||
visibleMetrics.add(metric);
|
visibleFields.add(fieldKey);
|
||||||
} else {
|
} else {
|
||||||
if (visibleMetrics.length == 1 && visibleMetrics.contains(metric)) {
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
visibleMetrics.remove(metric);
|
visibleFields.remove(fieldKey);
|
||||||
}
|
}
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setFieldSpan(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
int span,
|
||||||
|
) async {
|
||||||
|
final sensorSpans = _fieldSpansBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => <String, int>{},
|
||||||
|
);
|
||||||
|
sensorSpans[fieldKey] = span == 2 ? 2 : 1;
|
||||||
|
await _persistFieldSpans();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool isWatched(String publicKeyHex) =>
|
bool isWatched(String publicKeyHex) =>
|
||||||
_watchedSensorKeys.contains(publicKeyHex);
|
_watchedSensorKeys.contains(publicKeyHex);
|
||||||
|
|
||||||
@@ -151,19 +179,24 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
_visibleMetricsBySensor[contact.publicKeyHex] = Set<SensorMetric>.from(
|
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
||||||
_defaultVisibleMetrics,
|
_defaultVisibleFields,
|
||||||
);
|
);
|
||||||
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeSensor(String publicKeyHex) async {
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
_watchedSensorKeys.remove(publicKeyHex);
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
_refreshStates.remove(publicKeyHex);
|
_refreshStates.remove(publicKeyHex);
|
||||||
_visibleMetricsBySensor.remove(publicKeyHex);
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,31 +223,11 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in _watchedSensorKeys) {
|
||||||
Contact? contact;
|
await refreshSensor(
|
||||||
for (final entry in contactsProvider.contacts) {
|
publicKeyHex: key,
|
||||||
if (entry.publicKeyHex == key) {
|
contactsProvider: contactsProvider,
|
||||||
contact = entry;
|
connectionProvider: connectionProvider,
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (contact == null) {
|
|
||||||
_refreshStates[key] = SensorRefreshState.unavailable;
|
|
||||||
notifyListeners();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_refreshStates[key] = SensorRefreshState.refreshing;
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
final result = await connectionProvider.smartPing(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
hasPath: contact.hasPath,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_refreshStates[key] = result.success
|
|
||||||
? SensorRefreshState.success
|
|
||||||
: SensorRefreshState.timeout;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isRefreshingAll = false;
|
_isRefreshingAll = false;
|
||||||
@@ -222,12 +235,66 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SensorMetric? _metricFromName(String name) {
|
Future<void> refreshSensor({
|
||||||
for (final metric in SensorMetric.values) {
|
required String publicKeyHex,
|
||||||
if (metric.name == name) {
|
required ContactsProvider contactsProvider,
|
||||||
return metric;
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) async {
|
||||||
|
Contact? contact;
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
contact = entry;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
if (contact == null) {
|
||||||
|
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
|
||||||
|
|
||||||
|
final result = await connectionProvider.smartPing(
|
||||||
|
contactPublicKey: contact.publicKey,
|
||||||
|
hasPath: contact.hasPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
_setRefreshState(
|
||||||
|
publicKeyHex,
|
||||||
|
result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearExpiredRefreshStates({DateTime? now}) {
|
||||||
|
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||||
|
final keysToClear = <String>[];
|
||||||
|
|
||||||
|
for (final entry in _refreshStates.entries) {
|
||||||
|
if (entry.value != SensorRefreshState.success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedAt = _refreshStateUpdatedAt[entry.key];
|
||||||
|
if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
|
||||||
|
keysToClear.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keysToClear.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final key in keysToClear) {
|
||||||
|
_refreshStates.remove(key);
|
||||||
|
_refreshStateUpdatedAt.remove(key);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
|
||||||
|
_refreshStates[publicKeyHex] = state;
|
||||||
|
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final decodedPcm = await _codec.decodePackets(
|
||||||
|
session.packets,
|
||||||
|
session.mode,
|
||||||
|
);
|
||||||
|
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||||
_playingSessionId = sessionId;
|
_playingSessionId = sessionId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _player.play(pcm);
|
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,19 +14,24 @@ import 'messages_tab.dart';
|
|||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'map_management_screen.dart';
|
import 'repeaters_map_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'live_traffic_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
|
enum _AdvertMode { flood, direct }
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
final Function(AppThemeMode) onThemeChanged;
|
final Function(AppThemeMode) onThemeChanged;
|
||||||
final Function(Locale?) onLocaleChanged;
|
final Function(Locale?) onLocaleChanged;
|
||||||
@@ -47,15 +52,18 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
class _HomeScreenState extends State<HomeScreen>
|
||||||
|
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late final AppProvider _appProvider;
|
late final AppProvider _appProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
|
bool _isDeveloperModeEnabled = false;
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
|
||||||
List<_HomeTab> get _enabledTabs {
|
List<_HomeTab> get _enabledTabs {
|
||||||
return [
|
return [
|
||||||
@@ -77,6 +85,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_appProvider = context.read<AppProvider>();
|
_appProvider = context.read<AppProvider>();
|
||||||
_isMapEnabled = _appProvider.isMapEnabled;
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
@@ -86,6 +95,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
_loadDeveloperModePreference();
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
if (widget.shouldShowPermissionDialog) {
|
if (widget.shouldShowPermissionDialog) {
|
||||||
@@ -182,12 +193,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleTabActivated(_HomeTab tab) {
|
void _handleTabActivated(_HomeTab tab) {
|
||||||
|
_syncFastLocationUiState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
break;
|
break;
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
context.read<ContactsProvider>().markAllAsViewed();
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
@@ -200,6 +211,27 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _syncFastLocationUiState() {
|
||||||
|
final isActiveTab =
|
||||||
|
_currentTab == _HomeTab.map || _currentTab == _HomeTab.messages;
|
||||||
|
_appProvider.setFastLocationUiActive(
|
||||||
|
_lifecycleState == AppLifecycleState.resumed && isActiveTab,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<void> _loadRxTxPreference() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -209,8 +241,18 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperModePreference() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_appProvider.setFastLocationUiActive(false);
|
||||||
_appProvider.removeListener(_handleAppProviderChanged);
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
@@ -245,7 +287,191 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _advertiseDevice(BuildContext context) async {
|
Future<void> _triggerAdvertFeedback() async {
|
||||||
|
final platform = Theme.of(context).platform;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (platform == TargetPlatform.iOS) {
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
} else {
|
||||||
|
if (await Vibration.hasVibrator()) {
|
||||||
|
await Vibration.vibrate(duration: 50);
|
||||||
|
} else {
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Haptic feedback error: $e');
|
||||||
|
await HapticFeedback.vibrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return showModalBottomSheet<_AdvertMode>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advert mode',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Choose how far this announcement should travel.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.hub_rounded,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(l10n.flood),
|
||||||
|
subtitle: const Text('Relay through repeaters across the mesh'),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.near_me_rounded,
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(l10n.direct),
|
||||||
|
subtitle: const Text('Nearby only, without repeater flooding'),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityBadge({
|
||||||
|
required String label,
|
||||||
|
required int count,
|
||||||
|
required bool isActive,
|
||||||
|
required Color activeColor,
|
||||||
|
bool compact = false,
|
||||||
|
}) {
|
||||||
|
final color = isActive ? activeColor : Colors.grey;
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 7 : 8,
|
||||||
|
vertical: compact ? 4 : 5,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: compact ? 6 : 7,
|
||||||
|
height: compact ? 6 : 7,
|
||||||
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||||
|
),
|
||||||
|
SizedBox(width: compact ? 5 : 6),
|
||||||
|
Text(
|
||||||
|
'$label:$count',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: compact ? 10 : 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactActivityIndicator({
|
||||||
|
required bool rxActive,
|
||||||
|
required bool txActive,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: rxActive ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: txActive ? Colors.blue : Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _advertiseDevice(
|
||||||
|
BuildContext context, {
|
||||||
|
bool floodMode = true,
|
||||||
|
}) async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
@@ -325,8 +551,14 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
// Small delay to ensure the lat/lon is set
|
// Small delay to ensure the lat/lon is set
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
// Send flood advertisement
|
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
|
||||||
await connectionProvider.sendSelfAdvert(floodMode: true);
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastLogger.success(
|
||||||
|
context,
|
||||||
|
floodMode ? 'Flood advert sent' : 'Direct advert sent',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ Failed to advertise device: $e');
|
debugPrint('❌ Failed to advertise device: $e');
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -367,6 +599,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
appBar: shouldHideUI
|
appBar: shouldHideUI
|
||||||
? null
|
? null
|
||||||
: AppBar(
|
: AppBar(
|
||||||
|
toolbarHeight: 64,
|
||||||
|
titleSpacing: 8,
|
||||||
title: _buildCompactStatusBar(),
|
title: _buildCompactStatusBar(),
|
||||||
actions: [
|
actions: [
|
||||||
Consumer<ConnectionProvider>(
|
Consumer<ConnectionProvider>(
|
||||||
@@ -389,31 +623,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) {
|
||||||
PopupMenuItem(
|
final items = <PopupMenuEntry<void>>[];
|
||||||
child: Row(
|
|
||||||
children: [
|
if (_isDeveloperModeEnabled) {
|
||||||
const Icon(Icons.map),
|
items.add(
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(AppLocalizations.of(context)!.mapManagement),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
// Capture context-dependent objects before async gap
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
Future.delayed(Duration.zero, () {
|
|
||||||
if (!mounted) return;
|
|
||||||
navigator.push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => MapManagementScreen(
|
|
||||||
tileCacheService: appProvider.tileCacheService,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -428,12 +642,77 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
navigator.push(
|
navigator.push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const SpectrumScanScreen(),
|
builder: (context) =>
|
||||||
|
const SpectrumScanScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.radar_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Live Traffic'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.router_outlined),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Repeaters Map'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const RepeatersMapScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -443,7 +722,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// Capture context-dependent objects before async gap
|
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
Future.delayed(Duration.zero, () async {
|
Future.delayed(Duration.zero, () async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -457,12 +735,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Reload preference when returning from settings
|
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
_loadDeveloperModePreference();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -481,6 +762,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
);
|
);
|
||||||
case _HomeTab.sensors:
|
case _HomeTab.sensors:
|
||||||
return const SensorsTab();
|
return const SensorsTab();
|
||||||
@@ -530,7 +812,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
Icons.message,
|
Icons.message,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
),
|
),
|
||||||
text: AppLocalizations.of(context)!.messages,
|
text: 'Chat',
|
||||||
);
|
);
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
return Tab(
|
return Tab(
|
||||||
@@ -565,7 +847,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
final deviceInfo = provider.deviceInfo;
|
final deviceInfo = provider.deviceInfo;
|
||||||
final isConnected = deviceInfo.isConnected;
|
final isConnected = deviceInfo.isConnected;
|
||||||
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
final isBleConnected = isConnected && !isTcpConnected;
|
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
// Disconnected state: show connect button
|
||||||
@@ -627,64 +908,75 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connected state: LEFT | CENTER | RIGHT layout
|
final theme = Theme.of(context);
|
||||||
|
final subtitleColor = theme.colorScheme.onSurfaceVariant;
|
||||||
|
final signalColor = isTcpConnected
|
||||||
|
? Colors.green
|
||||||
|
: (deviceInfo.signalRssi != null
|
||||||
|
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
|
||||||
|
: Colors.grey);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isTight = constraints.maxWidth < 360;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// LEFT: Name + BT/Battery + Cog
|
Flexible(
|
||||||
Expanded(
|
fit: FlexFit.loose,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
deviceInfo.selfName ??
|
deviceInfo.selfName ??
|
||||||
AppLocalizations.of(context)!.appTitle,
|
AppLocalizations.of(context)!.appTitle,
|
||||||
style: const TextStyle(
|
style:
|
||||||
fontSize: 18,
|
(isTight
|
||||||
fontWeight: FontWeight.bold,
|
? theme.textTheme.titleSmall
|
||||||
),
|
: theme.textTheme.titleMedium)
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isTcpConnected
|
isTcpConnected
|
||||||
? Icons.wifi
|
? Icons.wifi_rounded
|
||||||
: Icons.bluetooth_connected,
|
: Icons.bluetooth_connected_rounded,
|
||||||
color: isTcpConnected
|
|
||||||
? Colors.green
|
|
||||||
: (deviceInfo.signalRssi != null
|
|
||||||
? BatteryDisplayHelper.getSignalColor(
|
|
||||||
deviceInfo.signalRssi!,
|
|
||||||
)
|
|
||||||
: Colors.grey),
|
|
||||||
size: 13,
|
size: 13,
|
||||||
|
color: signalColor,
|
||||||
),
|
),
|
||||||
if (isBleConnected &&
|
if (!isTcpConnected &&
|
||||||
deviceInfo.signalRssi != null) ...[
|
deviceInfo.signalRssi != null) ...[
|
||||||
const SizedBox(width: 3),
|
const SizedBox(width: 4),
|
||||||
Text(
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Text(
|
||||||
'${deviceInfo.signalRssi}',
|
'${deviceInfo.signalRssi}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
color: BatteryDisplayHelper.getSignalColor(
|
fontWeight: FontWeight.w600,
|
||||||
deviceInfo.signalRssi!,
|
color: signalColor,
|
||||||
),
|
),
|
||||||
),
|
maxLines: 1,
|
||||||
),
|
|
||||||
],
|
|
||||||
if (isTcpConnected) ...[
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'WiFi',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -694,36 +986,42 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
BatteryDisplayHelper.getBatteryIcon(
|
BatteryDisplayHelper.getBatteryIcon(
|
||||||
deviceInfo.batteryPercent!,
|
deviceInfo.batteryPercent!,
|
||||||
),
|
),
|
||||||
color: BatteryDisplayHelper.getBatteryColor(
|
|
||||||
deviceInfo.batteryPercent!,
|
|
||||||
),
|
|
||||||
size: 13,
|
size: 13,
|
||||||
|
color:
|
||||||
|
BatteryDisplayHelper.getBatteryColor(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 3),
|
),
|
||||||
Text(
|
const SizedBox(width: 4),
|
||||||
|
SizedBox(
|
||||||
|
width: 30,
|
||||||
|
child: Text(
|
||||||
'${deviceInfo.batteryPercent!.round()}%',
|
'${deviceInfo.batteryPercent!.round()}%',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
color: BatteryDisplayHelper.getBatteryColor(
|
fontWeight: FontWeight.w600,
|
||||||
|
color:
|
||||||
|
BatteryDisplayHelper.getBatteryColor(
|
||||||
deviceInfo.batteryPercent!,
|
deviceInfo.batteryPercent!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Settings cog - hidden in simple mode
|
const SizedBox(width: 4),
|
||||||
if (!context.watch<AppProvider>().isSimpleMode) ...[
|
IconButton(
|
||||||
const SizedBox(width: 8),
|
onPressed: () {
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const DeviceConfigScreen(),
|
builder: (context) =>
|
||||||
|
const DeviceConfigScreen(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -737,63 +1035,68 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
tooltip: AppLocalizations.of(context)!.settings,
|
||||||
width: 32,
|
icon: const Icon(Icons.tune_rounded),
|
||||||
height: 32,
|
color: subtitleColor,
|
||||||
alignment: Alignment.center,
|
style: IconButton.styleFrom(
|
||||||
child: const Icon(Icons.settings, size: 18),
|
backgroundColor: theme.colorScheme.surface,
|
||||||
|
foregroundColor: subtitleColor,
|
||||||
|
minimumSize: Size.square(isTight ? 38 : 40),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(width: 4),
|
||||||
],
|
GestureDetector(
|
||||||
),
|
onTap: () async {
|
||||||
),
|
await _triggerAdvertFeedback();
|
||||||
|
if (!mounted || !context.mounted) return;
|
||||||
// CENTER: Broadcast button
|
await _advertiseDevice(context);
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () async {
|
|
||||||
// Capture platform before async operations
|
|
||||||
final platform = Theme.of(context).platform;
|
|
||||||
// iOS: Use haptic feedback (always works)
|
|
||||||
// Android: Try vibration package for better control
|
|
||||||
try {
|
|
||||||
if (platform == TargetPlatform.iOS) {
|
|
||||||
// iOS: Try multiple haptic types for reliability
|
|
||||||
await HapticFeedback.lightImpact();
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
await HapticFeedback.lightImpact();
|
|
||||||
} else {
|
|
||||||
// Android vibration
|
|
||||||
if (await Vibration.hasVibrator()) {
|
|
||||||
await Vibration.vibrate(duration: 50);
|
|
||||||
} else {
|
|
||||||
await HapticFeedback.mediumImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback if anything fails
|
|
||||||
debugPrint('Haptic feedback error: $e');
|
|
||||||
await HapticFeedback.vibrate();
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
_advertiseDevice(context);
|
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(
|
onLongPress: () async {
|
||||||
backgroundColor: Colors.blue.shade700,
|
await _triggerAdvertFeedback();
|
||||||
foregroundColor: Colors.white,
|
if (!mounted || !context.mounted) return;
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
minimumSize: const Size(40, 40),
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.campaign, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
|
|
||||||
// RIGHT: RX/TX indicators
|
final mode = await _showAdvertModeSheet(context);
|
||||||
|
if (!mounted || !context.mounted || mode == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _advertiseDevice(
|
||||||
|
context,
|
||||||
|
floodMode: mode == _AdvertMode.flood,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: isTight ? 38 : 40,
|
||||||
|
height: isTight ? 38 : 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary,
|
||||||
|
theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.78,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.campaign_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: isTight ? 18 : 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
if (_showRxTxIndicators)
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
onTap: () => _openLiveTraffic(provider),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -803,68 +1106,54 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
child: isTight
|
||||||
|
? _buildCompactActivityIndicator(
|
||||||
|
rxActive: provider.rxActivity,
|
||||||
|
txActive: provider.txActivity,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh
|
||||||
|
.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_buildActivityBadge(
|
||||||
mainAxisSize: MainAxisSize.min,
|
label: 'RX',
|
||||||
children: [
|
count: provider.rxPacketCount,
|
||||||
Container(
|
isActive: provider.rxActivity,
|
||||||
width: 7,
|
activeColor: Colors.green,
|
||||||
height: 7,
|
compact: true,
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: provider.rxActivity
|
|
||||||
? Colors.green
|
|
||||||
: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'RX:${provider.rxPacketCount}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildActivityBadge(
|
||||||
|
label: 'TX',
|
||||||
|
count: provider.txPacketCount,
|
||||||
|
isActive: provider.txActivity,
|
||||||
|
activeColor: Colors.blue,
|
||||||
|
compact: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: provider.txActivity
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 3),
|
|
||||||
Text(
|
|
||||||
'TX:${provider.txPacketCount}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
const SizedBox(
|
SizedBox(width: isTight ? 24 : 74),
|
||||||
width: 52,
|
|
||||||
), // Placeholder to maintain layout balance
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build tab icon with badge showing count
|
/// Build tab icon with badge showing count
|
||||||
|
|||||||
1013
lib/screens/live_traffic_screen.dart
Normal file
1013
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
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
bool _isSendingVoice = false;
|
bool _isSendingVoice = false;
|
||||||
static const int _maxVoicePackets = 10;
|
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
|
||||||
static const double _silenceRmsThreshold = 500.0;
|
static const double _silenceRmsThreshold = 500.0;
|
||||||
static const double _silencePeakThreshold = 1400.0;
|
static const double _silencePeakThreshold = 1400.0;
|
||||||
static const int _maxInteriorSilentChunks = 2;
|
static const int _maxInteriorSilentChunks = 2;
|
||||||
@@ -88,15 +88,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_textController.addListener(_updateCharacterCount);
|
_textController.addListener(_updateCharacterCount);
|
||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceBitrate();
|
_loadVoiceSettings();
|
||||||
// Mark all messages as read when tab is opened
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitrate() async {
|
Future<void> _loadVoiceSettings() async {
|
||||||
final bitrate = await VoiceBitratePreferences.getBitrate();
|
final bitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -128,11 +126,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
void _checkForNavigationRequest() {
|
void _checkForNavigationRequest() {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final targetMessageId = messagesProvider.targetMessageId;
|
final targetMessageId = messagesProvider.targetMessageId;
|
||||||
|
final targetDestinationType = messagesProvider.targetDestinationType;
|
||||||
|
|
||||||
if (targetMessageId != null) {
|
if (targetMessageId != null) {
|
||||||
_scrollToMessage(targetMessageId);
|
_scrollToMessage(targetMessageId);
|
||||||
messagesProvider.clearMessageNavigation();
|
messagesProvider.clearMessageNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetDestinationType != null) {
|
||||||
|
_applyPendingDestination(
|
||||||
|
type: targetDestinationType,
|
||||||
|
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
|
||||||
|
);
|
||||||
|
messagesProvider.clearDestinationNavigation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToMessage(String messageId) {
|
void _scrollToMessage(String messageId) {
|
||||||
@@ -258,6 +265,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
void _showRecipientSelector() {
|
void _showRecipientSelector() {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
// Filter contacts by type
|
// Filter contacts by type
|
||||||
final contacts = contactsProvider.contacts
|
final contacts = contactsProvider.contacts
|
||||||
@@ -278,6 +286,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
|
unreadCount: messagesProvider.unreadCount,
|
||||||
|
unreadCountsByPublicKey: {
|
||||||
|
for (final contact in [...contacts, ...rooms, ...channels])
|
||||||
|
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
|
||||||
|
contact,
|
||||||
|
),
|
||||||
|
},
|
||||||
currentDestinationType: _destinationType,
|
currentDestinationType: _destinationType,
|
||||||
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
||||||
onSelect: _onRecipientSelected,
|
onSelect: _onRecipientSelected,
|
||||||
@@ -292,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
@@ -304,9 +321,107 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyPendingDestination({
|
||||||
|
required String type,
|
||||||
|
String? recipientPublicKeyHex,
|
||||||
|
}) async {
|
||||||
|
Contact? recipient;
|
||||||
|
if (recipientPublicKeyHex != null) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
recipient = contactsProvider.contacts.where((contact) {
|
||||||
|
return contact.publicKeyHex == recipientPublicKeyHex;
|
||||||
|
}).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(type, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _insertReplyMention(String displayName) {
|
||||||
|
final trimmedName = displayName.trim();
|
||||||
|
if (trimmedName.isEmpty) return;
|
||||||
|
|
||||||
|
final mention = '@[$trimmedName] ';
|
||||||
|
final value = _textController.value;
|
||||||
|
final selection = 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;
|
||||||
|
|
||||||
|
_textController.value = value.copyWith(
|
||||||
|
text: nextText,
|
||||||
|
selection: TextSelection.collapsed(offset: nextOffset),
|
||||||
|
composing: TextRange.empty,
|
||||||
|
);
|
||||||
|
_enforceMessageByteLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replyToMessage(Message message) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
Contact? senderContact;
|
||||||
|
|
||||||
|
String destinationType;
|
||||||
|
Contact? recipient;
|
||||||
|
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
final channelIdx = message.channelIdx ?? 0;
|
||||||
|
destinationType = MessageDestinationPreferences.destinationTypeChannel;
|
||||||
|
|
||||||
|
if (channelIdx != 0) {
|
||||||
|
recipient = contactsProvider.channels.where((contact) {
|
||||||
|
return contact.publicKey.length > 1 &&
|
||||||
|
contact.publicKey[1] == channelIdx;
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
|
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationType = recipient.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(destinationType, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (message.isChannelMessage && senderContact != null) {
|
||||||
|
_insertReplyMention(senderContact.displayName);
|
||||||
|
}
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get icon for current destination type
|
/// Get icon for current destination type
|
||||||
IconData _getDestinationIcon() {
|
IconData _getDestinationIcon() {
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return Icons.all_inbox;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
return Icons.public;
|
return Icons.public;
|
||||||
} else if (_destinationType ==
|
} else if (_destinationType ==
|
||||||
@@ -318,6 +433,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getDestinationLabel() {
|
String _getDestinationLabel() {
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return AppLocalizations.of(context)!.showAll;
|
||||||
|
}
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
@@ -343,6 +461,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, 'Select a channel, contact, or room first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check destination type and send accordingly
|
// Check destination type and send accordingly
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
@@ -375,6 +499,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -692,7 +817,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Future<void> _startVoiceRecording() async {
|
Future<void> _startVoiceRecording() async {
|
||||||
if (_isSendingVoice || _isRecording) return;
|
if (_isSendingVoice || _isRecording) return;
|
||||||
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
debugPrint('🎙️ [Voice] _startVoiceRecording called');
|
||||||
// Read fresh bitrate preference so settings changes apply immediately.
|
// Read fresh voice preferences so settings changes apply immediately.
|
||||||
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -731,11 +856,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedVoiceBitrate,
|
_selectedVoiceBitrate,
|
||||||
);
|
);
|
||||||
final packetDuration = Duration(
|
final packetDuration = Duration(
|
||||||
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
|
milliseconds: _activeVoiceMode!.packetDurationMs,
|
||||||
);
|
);
|
||||||
|
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
|
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
|
||||||
);
|
);
|
||||||
|
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
@@ -744,9 +870,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
try {
|
try {
|
||||||
final stream = _voiceRecorder.startCapture(
|
final stream = _voiceRecorder.startCapture(
|
||||||
chunkDuration: packetDuration,
|
chunkDuration: packetDuration,
|
||||||
|
sampleRateHz: _activeVoiceMode!.sampleRateHz,
|
||||||
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
enableCompressor: appProvider.isVoiceCompressorEnabled,
|
||||||
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
enableLimiter: appProvider.isVoiceLimiterEnabled,
|
||||||
|
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
);
|
);
|
||||||
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
|
||||||
_voiceStreamSub = stream.listen(
|
_voiceStreamSub = stream.listen(
|
||||||
@@ -757,7 +887,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
|
||||||
);
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (_recordedChunks.length >= _maxVoicePackets) {
|
if (_recordedChunks.length >= maxVoicePackets) {
|
||||||
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
debugPrint('🎙️ [Voice] max packets reached, stopping');
|
||||||
_stopAndSendVoice();
|
_stopAndSendVoice();
|
||||||
}
|
}
|
||||||
@@ -774,6 +904,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _maxVoicePacketsForMode(VoicePacketMode mode) {
|
||||||
|
final packets =
|
||||||
|
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
|
||||||
|
return packets < 1 ? 1 : packets;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _stopAndSendVoice() async {
|
Future<void> _stopAndSendVoice() async {
|
||||||
if (!_isRecording) return;
|
if (!_isRecording) return;
|
||||||
final trimSilenceEnabled = context
|
final trimSilenceEnabled = context
|
||||||
@@ -788,9 +924,14 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await _voiceRecorder.stopCapture();
|
await _voiceRecorder.stopCapture();
|
||||||
|
|
||||||
final rawChunks = List<Int16List>.from(_recordedChunks);
|
final rawChunks = List<Int16List>.from(_recordedChunks);
|
||||||
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
|
final trimmedChunks = trimSilenceEnabled
|
||||||
|
? _trimSilence(rawChunks)
|
||||||
|
: rawChunks;
|
||||||
final sessionId = _currentVoiceSessionId;
|
final sessionId = _currentVoiceSessionId;
|
||||||
final mode = _activeVoiceMode;
|
final mode = _activeVoiceMode;
|
||||||
|
final chunks = mode == null
|
||||||
|
? trimmedChunks
|
||||||
|
: _prepareChunksForSending(trimmedChunks, mode);
|
||||||
_recordedChunks.clear();
|
_recordedChunks.clear();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -979,6 +1120,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
messagesProvider.markMessageSent(msgId, 0, 0);
|
messagesProvider.markMessageSent(msgId, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Int16List> _prepareChunksForSending(
|
||||||
|
List<Int16List> chunks,
|
||||||
|
VoicePacketMode mode,
|
||||||
|
) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
List<Int16List> _trimSilence(List<Int16List> chunks) {
|
||||||
if (chunks.isEmpty) return chunks;
|
if (chunks.isEmpty) return chunks;
|
||||||
|
|
||||||
@@ -1008,16 +1156,28 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
bool _isSilentChunk(Int16List chunk) {
|
bool _isSilentChunk(Int16List chunk) {
|
||||||
if (chunk.isEmpty) return true;
|
if (chunk.isEmpty) return true;
|
||||||
|
|
||||||
|
final rms = _chunkRms(chunk);
|
||||||
|
final peak = _chunkPeak(chunk);
|
||||||
|
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _chunkRms(Int16List chunk) {
|
||||||
var sumSquares = 0.0;
|
var sumSquares = 0.0;
|
||||||
|
for (final sample in chunk) {
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
return math.sqrt(sumSquares / chunk.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _chunkPeak(Int16List chunk) {
|
||||||
var peak = 0;
|
var peak = 0;
|
||||||
for (final sample in chunk) {
|
for (final sample in chunk) {
|
||||||
final absSample = sample.abs();
|
final absSample = sample.abs();
|
||||||
if (absSample > peak) peak = absSample;
|
if (absSample > peak) {
|
||||||
sumSquares += sample * sample;
|
peak = absSample;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
final rms = math.sqrt(sumSquares / chunk.length);
|
return peak;
|
||||||
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isPublicChannelSelected() {
|
bool _isPublicChannelSelected() {
|
||||||
@@ -1107,6 +1267,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.search),
|
||||||
|
title: const Text('Search messages'),
|
||||||
|
onTap: () async {
|
||||||
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
|
_showFilteredMessageSearch();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_location_alt),
|
leading: const Icon(Icons.add_location_alt),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||||
@@ -1174,6 +1343,116 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFilteredMessageSearch() {
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final scopedMessages = _getFilteredMessages(messagesProvider);
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
final matches = query.isEmpty
|
||||||
|
? scopedMessages
|
||||||
|
: scopedMessages.where((message) {
|
||||||
|
return message.text.toLowerCase().contains(query) ||
|
||||||
|
(message.senderName?.toLowerCase().contains(query) ??
|
||||||
|
false);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search in current filter',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: searchController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
searchController.clear();
|
||||||
|
setModalState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setModalState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: matches.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
query.isEmpty
|
||||||
|
? 'No messages in this filter'
|
||||||
|
: 'No matches in this filter',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: matches.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = matches[index];
|
||||||
|
final title =
|
||||||
|
message.senderName?.trim().isNotEmpty ==
|
||||||
|
true
|
||||||
|
? message.senderName!
|
||||||
|
: message.isSentMessage
|
||||||
|
? 'You'
|
||||||
|
: _getDestinationLabel();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
message.text,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_scrollToMessage(message.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).whenComplete(searchController.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDestinationAvatar(BuildContext context) {
|
Widget _buildDestinationAvatar(BuildContext context) {
|
||||||
final recipient = _selectedRecipient;
|
final recipient = _selectedRecipient;
|
||||||
if (recipient != null) {
|
if (recipient != null) {
|
||||||
@@ -1432,29 +1711,33 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markCurrentDestinationAsRead() {
|
||||||
|
context.read<MessagesProvider>().markDestinationAsRead(
|
||||||
|
destinationType: _destinationType,
|
||||||
|
contact: _selectedRecipient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
|
|
||||||
// Get simple mode setting from AppProvider
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
|
|
||||||
List<Message> filteredMessages;
|
List<Message> filteredMessages;
|
||||||
|
|
||||||
// If channel destination is selected, filter by selected channel.
|
// If channel destination is selected, filter by selected channel.
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
filteredMessages = allMessages;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
if (selectedChannelIdx == 0) {
|
|
||||||
// Public channel view keeps showing all messages (current app behavior).
|
|
||||||
filteredMessages = allMessages;
|
|
||||||
} else {
|
|
||||||
filteredMessages = allMessages
|
filteredMessages = allMessages
|
||||||
.where((message) => message.channelIdx == selectedChannelIdx)
|
.where(
|
||||||
|
(message) =>
|
||||||
|
message.isChannelMessage &&
|
||||||
|
(message.channelIdx ?? 0) == selectedChannelIdx,
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// If a contact or room is selected, filter by recipient/sender prefixes.
|
// If a contact or room is selected, filter by recipient/sender prefixes.
|
||||||
else if ((_destinationType ==
|
else if ((_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeContact ||
|
MessageDestinationPreferences.destinationTypeContact ||
|
||||||
@@ -1462,6 +1745,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
MessageDestinationPreferences.destinationTypeRoom) &&
|
MessageDestinationPreferences.destinationTypeRoom) &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
filteredMessages = allMessages.where((message) {
|
filteredMessages = allMessages.where((message) {
|
||||||
|
if (!message.isContactMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Include messages sent TO this recipient
|
// Include messages sent TO this recipient
|
||||||
if (message.recipientPublicKey != null &&
|
if (message.recipientPublicKey != null &&
|
||||||
message.recipientPublicKey!.length >= 6 &&
|
message.recipientPublicKey!.length >= 6 &&
|
||||||
@@ -1492,16 +1779,11 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
filteredMessages = allMessages;
|
filteredMessages = allMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In simple mode, filter out system messages (toast logs)
|
return filteredMessages
|
||||||
if (isSimpleMode) {
|
|
||||||
filteredMessages = filteredMessages
|
|
||||||
.where((message) => !message.isSystemMessage)
|
.where((message) => !message.isSystemMessage)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredMessages;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleMessageTap(Message message) {
|
void _handleMessageTap(Message message) {
|
||||||
if (widget.onNavigateToMap == null) return;
|
if (widget.onNavigateToMap == null) return;
|
||||||
|
|
||||||
@@ -1547,6 +1829,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
onMessageTap: _handleMessageTap,
|
onMessageTap: _handleMessageTap,
|
||||||
|
onReplyToMessage: _replyToMessage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
class PacketLogScreen extends StatefulWidget {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final contacts = context.watch<ContactsProvider>().contacts;
|
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
|
|
||||||
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
|
||||||
final ownName =
|
|
||||||
connectionProvider.deviceInfo.selfName ??
|
|
||||||
connectionProvider.deviceInfo.displayName;
|
|
||||||
final resolvedPath = decodedRoute?.pathHashes
|
|
||||||
.map(
|
|
||||||
(hash) => LogRxRouteDecoder.resolveHash(
|
|
||||||
hash,
|
|
||||||
contacts: contacts,
|
|
||||||
ownPublicKey: ownPublicKey,
|
|
||||||
ownName: ownName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
|
|
||||||
? resolvedPath.first
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isRx && decodedRoute != null) ...[
|
if (isRx) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_RouteSection(
|
_DecodedRouteSection(log: log),
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath ?? const [],
|
|
||||||
originalSender: originalSender,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
@@ -755,6 +733,97 @@ 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();
|
||||||
|
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
|
||||||
|
|
||||||
|
return _RouteSection(
|
||||||
|
route: decodedRoute,
|
||||||
|
path: resolvedPath,
|
||||||
|
originalSender: originalSender,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedNodeHash _resolveHashWithFallback(
|
||||||
|
String hashHex, {
|
||||||
|
required List<Contact> contacts,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
);
|
||||||
|
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
|
final matches = cachedNodes
|
||||||
|
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return ResolvedNodeHash(
|
||||||
|
hashHex: normalizedHashHex,
|
||||||
|
label: matches.first.name,
|
||||||
|
isOwnNode: false,
|
||||||
|
isUniqueMatch: matches.length == 1,
|
||||||
|
matchCount: matches.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
@@ -802,7 +871,13 @@ class _RouteSection extends StatelessWidget {
|
|||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.hub,
|
icon: Icons.hub,
|
||||||
label: 'Hops',
|
label: 'Hops',
|
||||||
value: '${route.pathHashes.length}',
|
value: '${route.hopCount}',
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: 'Hash size',
|
||||||
|
value:
|
||||||
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
if (originalSender != null)
|
||||||
_FactCard(
|
_FactCard(
|
||||||
|
|||||||
775
lib/screens/repeaters_map_screen.dart
Normal file
775
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class RepeatersMapScreen extends StatefulWidget {
|
||||||
|
const RepeatersMapScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
||||||
|
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
||||||
|
static const double _fallbackZoom = 7;
|
||||||
|
static const double _myLocationZoom = 14;
|
||||||
|
|
||||||
|
final flutter_map.MapController _mapController = flutter_map.MapController();
|
||||||
|
|
||||||
|
List<MeshMapNode> _onlineRepeaters = const [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _didFitCamera = false;
|
||||||
|
String? _error;
|
||||||
|
double _currentZoom = _fallbackZoom;
|
||||||
|
LatLng? _myLocation;
|
||||||
|
final Set<String> _addingRepeaters = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadRepeaters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
if (forceRefresh) {
|
||||||
|
_didFitCamera = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nodes = await MeshMapNodesService.fetchNodes(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
} catch (error) {
|
||||||
|
final cached = await MeshMapNodesService.loadCachedNodes();
|
||||||
|
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
_error = repeaters.isEmpty
|
||||||
|
? 'Unable to load repeaters right now.'
|
||||||
|
: 'Showing cached repeaters. Pull to retry.';
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_MapRepeater> _mergeRepeaters({
|
||||||
|
required List<MeshMapNode> onlineNodes,
|
||||||
|
required List<Contact> contactRepeaters,
|
||||||
|
}) {
|
||||||
|
final merged = <String, _MapRepeater>{};
|
||||||
|
|
||||||
|
for (final node in onlineNodes) {
|
||||||
|
merged[node.publicKey] = _MapRepeater(
|
||||||
|
publicKey: node.publicKey,
|
||||||
|
name: node.name,
|
||||||
|
latitude: node.latitude,
|
||||||
|
longitude: node.longitude,
|
||||||
|
isFromContacts: false,
|
||||||
|
isFromOnline: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactRepeaters) {
|
||||||
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = contact.publicKeyHex.toLowerCase();
|
||||||
|
final existing = merged[key];
|
||||||
|
merged[key] = _MapRepeater(
|
||||||
|
publicKey: key,
|
||||||
|
name: contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName
|
||||||
|
: existing?.name ?? 'Unknown repeater',
|
||||||
|
latitude: existing?.latitude ?? location.latitude,
|
||||||
|
longitude: existing?.longitude ?? location.longitude,
|
||||||
|
isFromContacts: true,
|
||||||
|
isFromOnline: existing?.isFromOnline ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaters = merged.values.toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return repeaters;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshMyLocation() async {
|
||||||
|
try {
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_myLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
||||||
|
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repeaters.length == 1) {
|
||||||
|
final repeater = repeaters.first;
|
||||||
|
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
||||||
|
_currentZoom = 13;
|
||||||
|
} else {
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
repeaters
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_didFitCamera = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRepeaterDetails(_MapRepeater repeater) {
|
||||||
|
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
||||||
|
final canAdd = !repeater.isFromContacts;
|
||||||
|
final isConnected = context
|
||||||
|
.read<ConnectionProvider>()
|
||||||
|
.deviceInfo
|
||||||
|
.isConnected;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
repeater.name,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Public key',
|
||||||
|
value: repeater.publicKey,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Coordinates',
|
||||||
|
value:
|
||||||
|
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
_InfoLine(label: 'Source', value: repeater.sourceLabel),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (canAdd)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: !isConnected || isAdding
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _addRepeaterToContacts(repeater);
|
||||||
|
},
|
||||||
|
icon: isAdding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(
|
||||||
|
!isConnected
|
||||||
|
? 'Connect device to add'
|
||||||
|
: 'Add to contacts',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: const Text('Already in contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
||||||
|
if (_addingRepeaters.contains(repeater.publicKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Connect to a device before adding contacts'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.add(repeater.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${repeater.name} added to contacts')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.remove(repeater.publicKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
||||||
|
return Uint8List.fromList(
|
||||||
|
List<int>.generate(
|
||||||
|
normalized.length ~/ 2,
|
||||||
|
(index) => int.parse(
|
||||||
|
normalized.substring(index * 2, index * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactsProvider = context.watch<ContactsProvider>();
|
||||||
|
final repeaters = _mergeRepeaters(
|
||||||
|
onlineNodes: _onlineRepeaters,
|
||||||
|
contactRepeaters: contactsProvider.repeaters,
|
||||||
|
);
|
||||||
|
_fitCameraIfNeeded(repeaters);
|
||||||
|
final clusters = _buildClusters(repeaters);
|
||||||
|
final markers = clusters.map((cluster) {
|
||||||
|
final markerColor = switch (cluster.status) {
|
||||||
|
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
||||||
|
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
||||||
|
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
||||||
|
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cluster.members.length == 1) {
|
||||||
|
final repeater = cluster.members.first;
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 80,
|
||||||
|
height: 100,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _showRepeaterDetails(repeater),
|
||||||
|
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _zoomToCluster(cluster),
|
||||||
|
child: _ClusterMarker(
|
||||||
|
color: markerColor,
|
||||||
|
count: cluster.members.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
final allMarkers = <flutter_map.Marker>[
|
||||||
|
...markers,
|
||||||
|
if (_myLocation != null)
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: _myLocation!,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Repeaters Map'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () => _loadRepeaters(forceRefresh: true),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
flutter_map.FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: _fallbackCenter,
|
||||||
|
initialZoom: _fallbackZoom,
|
||||||
|
onPositionChanged: (camera, hasGesture) {
|
||||||
|
final nextZoom = camera.zoom;
|
||||||
|
if (nextZoom != _currentZoom && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = nextZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'app.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(markers: allMarkers),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(_error!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||||
|
if (!_isLoading && repeaters.isEmpty)
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
_error ?? 'No repeaters available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: _error == null ? 16 : 72,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFF7C3AED),
|
||||||
|
label: 'From contacts',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFFE8681D),
|
||||||
|
label: 'Online only',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${repeaters.length} repeaters',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _jumpToMyLocation,
|
||||||
|
tooltip: 'My location',
|
||||||
|
child: const Icon(Icons.my_location),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _jumpToMyLocation() async {
|
||||||
|
await _refreshMyLocation();
|
||||||
|
final myLocation = _myLocation;
|
||||||
|
if (!mounted || myLocation == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mapController.move(myLocation, _myLocationZoom);
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = _myLocationZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
||||||
|
if (repeaters.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentZoom >= 11.5) {
|
||||||
|
return repeaters
|
||||||
|
.map(
|
||||||
|
(repeater) => _RepeaterCluster(
|
||||||
|
members: [repeater],
|
||||||
|
center: LatLng(repeater.latitude, repeater.longitude),
|
||||||
|
status: repeater.clusterStatus,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cellSize = _gridSizeForZoom(_currentZoom);
|
||||||
|
final buckets = <String, List<_MapRepeater>>{};
|
||||||
|
|
||||||
|
for (final repeater in repeaters) {
|
||||||
|
final latBucket = (repeater.latitude / cellSize).floor();
|
||||||
|
final lonBucket = (repeater.longitude / cellSize).floor();
|
||||||
|
final key = '$latBucket:$lonBucket';
|
||||||
|
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.values.map((members) {
|
||||||
|
final centerLat =
|
||||||
|
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final centerLon =
|
||||||
|
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final statuses = members.map((node) => node.clusterStatus).toSet();
|
||||||
|
return _RepeaterCluster(
|
||||||
|
members: members,
|
||||||
|
center: LatLng(centerLat, centerLon),
|
||||||
|
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _gridSizeForZoom(double zoom) {
|
||||||
|
if (zoom < 5) return 3.0;
|
||||||
|
if (zoom < 7) return 1.5;
|
||||||
|
if (zoom < 9) return 0.7;
|
||||||
|
if (zoom < 10.5) return 0.28;
|
||||||
|
return 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _zoomToCluster(_RepeaterCluster cluster) {
|
||||||
|
if (cluster.members.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
cluster.members
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(64),
|
||||||
|
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoLine extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool monospace;
|
||||||
|
|
||||||
|
const _InfoLine({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.monospace = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 92,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: monospace
|
||||||
|
? const TextStyle(fontFamily: 'monospace')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendRow extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _LegendRow({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeaterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _RepeaterMarker({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClusterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const _ClusterMarker({required this.color, required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
||||||
|
|
||||||
|
class _RepeaterCluster {
|
||||||
|
final List<_MapRepeater> members;
|
||||||
|
final LatLng center;
|
||||||
|
final _ClusterStatus status;
|
||||||
|
|
||||||
|
const _RepeaterCluster({
|
||||||
|
required this.members,
|
||||||
|
required this.center,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapRepeater {
|
||||||
|
final String publicKey;
|
||||||
|
final String name;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final bool isFromContacts;
|
||||||
|
final bool isFromOnline;
|
||||||
|
|
||||||
|
const _MapRepeater({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.name,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.isFromContacts,
|
||||||
|
required this.isFromOnline,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get sourceLabel {
|
||||||
|
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
||||||
|
if (isFromContacts) return 'Contacts';
|
||||||
|
return 'Online';
|
||||||
|
}
|
||||||
|
|
||||||
|
_ClusterStatus get clusterStatus {
|
||||||
|
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
||||||
|
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
||||||
|
return _ClusterStatus.onlineOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -6,10 +10,55 @@ import '../models/contact.dart';
|
|||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/sensors_provider.dart';
|
import '../providers/sensors_provider.dart';
|
||||||
|
import '../utils/location_formats.dart';
|
||||||
|
|
||||||
class SensorsTab extends StatelessWidget {
|
class SensorsTab extends StatefulWidget {
|
||||||
const SensorsTab({super.key});
|
const SensorsTab({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SensorsTab> createState() => _SensorsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SensorsTabState extends State<SensorsTab> {
|
||||||
|
Timer? _minuteTicker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scheduleMinuteTicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_minuteTicker?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleMinuteTicker() {
|
||||||
|
_minuteTicker?.cancel();
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final nextMinute = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
now.hour,
|
||||||
|
now.minute + 1,
|
||||||
|
);
|
||||||
|
final delay = nextMinute.difference(now);
|
||||||
|
|
||||||
|
_minuteTicker = Timer(delay, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<SensorsProvider>().clearExpiredRefreshStates();
|
||||||
|
setState(() {});
|
||||||
|
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
context.read<SensorsProvider>().clearExpiredRefreshStates();
|
||||||
|
setState(() {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showAddSensorSheet(BuildContext context) async {
|
Future<void> _showAddSensorSheet(BuildContext context) async {
|
||||||
final sensorsProvider = context.read<SensorsProvider>();
|
final sensorsProvider = context.read<SensorsProvider>();
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
@@ -83,15 +132,15 @@ class SensorsTab extends StatelessWidget {
|
|||||||
Future<void> _showMetricSelector(
|
Future<void> _showMetricSelector(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
|
Contact? contact,
|
||||||
) async {
|
) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
builder: (sheetContext) => Consumer<SensorsProvider>(
|
builder: (sheetContext) => Consumer<SensorsProvider>(
|
||||||
builder: (context, sensorsProvider, child) {
|
builder: (context, sensorsProvider, child) {
|
||||||
final visibleMetrics = sensorsProvider.visibleMetricsFor(
|
final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex);
|
||||||
publicKeyHex,
|
final options = _fieldOptionsFor(contact);
|
||||||
);
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
@@ -107,24 +156,48 @@ class SensorsTab extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Wrap(
|
...options.map((option) {
|
||||||
spacing: 10,
|
final visible = visibleFields.contains(option.key);
|
||||||
runSpacing: 10,
|
final span = sensorsProvider.fieldSpanFor(
|
||||||
children: SensorMetric.values.map((metric) {
|
publicKeyHex,
|
||||||
final visible = visibleMetrics.contains(metric);
|
option.key,
|
||||||
return FilterChip(
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilterChip(
|
||||||
selected: visible,
|
selected: visible,
|
||||||
label: Text(_metricLabel(metric)),
|
label: Text(option.label),
|
||||||
onSelected: (value) {
|
onSelected: (value) {
|
||||||
sensorsProvider.toggleMetric(
|
sensorsProvider.toggleMetric(
|
||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
metric,
|
option.key,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
SegmentedButton<int>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment<int>(value: 1, label: Text('1x')),
|
||||||
|
ButtonSegment<int>(value: 2, label: Text('2x')),
|
||||||
|
],
|
||||||
|
selected: <int>{span},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
sensorsProvider.setFieldSpan(
|
||||||
|
publicKeyHex,
|
||||||
|
option.key,
|
||||||
|
selection.first,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -170,11 +243,23 @@ class SensorsTab extends StatelessWidget {
|
|||||||
return _SensorCard(
|
return _SensorCard(
|
||||||
contact: contact,
|
contact: contact,
|
||||||
state: sensorsProvider.stateFor(key),
|
state: sensorsProvider.stateFor(key),
|
||||||
visibleMetrics: sensorsProvider.visibleMetricsFor(key),
|
visibleFields: sensorsProvider.visibleFieldsFor(key),
|
||||||
|
fieldSpans: {
|
||||||
|
for (final field in sensorsProvider.visibleFieldsFor(
|
||||||
|
key,
|
||||||
|
))
|
||||||
|
field: sensorsProvider.fieldSpanFor(key, field),
|
||||||
|
},
|
||||||
onRemove: () async {
|
onRemove: () async {
|
||||||
await sensorsProvider.removeSensor(key);
|
await sensorsProvider.removeSensor(key);
|
||||||
},
|
},
|
||||||
onCustomize: () => _showMetricSelector(context, key),
|
onCustomize: () =>
|
||||||
|
_showMetricSelector(context, key, contact),
|
||||||
|
onRefresh: () => sensorsProvider.refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: context.read<ConnectionProvider>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -265,15 +350,19 @@ class _EmptySensorsState extends StatelessWidget {
|
|||||||
class _SensorCard extends StatelessWidget {
|
class _SensorCard extends StatelessWidget {
|
||||||
final Contact? contact;
|
final Contact? contact;
|
||||||
final SensorRefreshState state;
|
final SensorRefreshState state;
|
||||||
final Set<SensorMetric> visibleMetrics;
|
final Set<String> visibleFields;
|
||||||
|
final Map<String, int> fieldSpans;
|
||||||
final Future<void> Function() onRemove;
|
final Future<void> Function() onRemove;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
final VoidCallback onCustomize;
|
final VoidCallback onCustomize;
|
||||||
|
|
||||||
const _SensorCard({
|
const _SensorCard({
|
||||||
required this.contact,
|
required this.contact,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.visibleMetrics,
|
required this.visibleFields,
|
||||||
|
required this.fieldSpans,
|
||||||
required this.onRemove,
|
required this.onRemove,
|
||||||
|
required this.onRefresh,
|
||||||
required this.onCustomize,
|
required this.onCustomize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +371,7 @@ class _SensorCard extends StatelessWidget {
|
|||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final telemetry = contact?.telemetry;
|
final telemetry = contact?.telemetry;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final metrics = contact == null || telemetry == null
|
final metrics = contact == null || telemetry == null
|
||||||
? const <_MetricCardData>[]
|
? const <_MetricCardData>[]
|
||||||
: _buildMetricCards(l10n, telemetry, contact!);
|
: _buildMetricCards(l10n, telemetry, contact!);
|
||||||
@@ -289,21 +379,28 @@ class _SensorCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.colorScheme.outline.withValues(alpha: 0.14),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
color: Colors.black.withValues(alpha: 0.045),
|
||||||
blurRadius: 18,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -331,30 +428,61 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (telemetry != null) ...[
|
if (telemetry != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}',
|
'${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (state == SensorRefreshState.refreshing)
|
||||||
|
const _InlineStateMeta(
|
||||||
|
label: 'Refreshing',
|
||||||
|
color: Color(0xFF266AC2),
|
||||||
|
spinning: true,
|
||||||
|
),
|
||||||
|
if (state == SensorRefreshState.success)
|
||||||
|
const _InlineStateMeta(
|
||||||
|
label: 'Updated',
|
||||||
|
color: Color(0xFF218B63),
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
),
|
||||||
|
if (state == SensorRefreshState.unavailable)
|
||||||
|
const _InlineStateMeta(
|
||||||
|
label: 'Unavailable',
|
||||||
|
color: Color(0xFFB13B55),
|
||||||
|
icon: Icons.error_outline,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'remove') {
|
if (value == 'refresh') {
|
||||||
|
await onRefresh();
|
||||||
|
} else if (value == 'remove') {
|
||||||
await onRemove();
|
await onRemove();
|
||||||
} else if (value == 'customize') {
|
} else if (value == 'customize') {
|
||||||
onCustomize();
|
onCustomize();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => const [
|
itemBuilder: (context) => [
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
|
value: 'refresh',
|
||||||
|
child: Text(l10n.refresh),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
value: 'customize',
|
value: 'customize',
|
||||||
child: Text('Customize fields'),
|
child: Text('Customize fields'),
|
||||||
),
|
),
|
||||||
PopupMenuItem<String>(
|
const PopupMenuItem<String>(
|
||||||
value: 'remove',
|
value: 'remove',
|
||||||
child: Text('Remove'),
|
child: Text('Remove'),
|
||||||
),
|
),
|
||||||
@@ -363,47 +491,41 @@ class _SensorCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (((state != SensorRefreshState.idle &&
|
|
||||||
state != SensorRefreshState.timeout) ||
|
|
||||||
state == SensorRefreshState.unavailable) ||
|
|
||||||
(contact != null &&
|
|
||||||
visibleMetrics.contains(SensorMetric.lastSeen)))
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: [
|
|
||||||
if (state != SensorRefreshState.idle &&
|
|
||||||
state != SensorRefreshState.timeout)
|
|
||||||
_StatusPill(state: state),
|
|
||||||
if (contact != null &&
|
|
||||||
visibleMetrics.contains(SensorMetric.lastSeen))
|
|
||||||
_InfoPill(
|
|
||||||
icon: Icons.schedule,
|
|
||||||
label:
|
|
||||||
'${l10n.lastSeen}: ${contact!.timeSinceLastSeen}',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (contact == null)
|
if (contact == null)
|
||||||
const Text(
|
const Text(
|
||||||
'This node is no longer available in the contact list.',
|
'This node is no longer available in the contact list.',
|
||||||
)
|
)
|
||||||
else if (telemetry == null)
|
else if (telemetry == null)
|
||||||
const Text('No telemetry received yet. Pull down to fetch it.')
|
const Text(
|
||||||
|
'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.',
|
||||||
|
)
|
||||||
else if (metrics.isEmpty)
|
else if (metrics.isEmpty)
|
||||||
const Text(
|
const Text(
|
||||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Wrap(
|
LayoutBuilder(
|
||||||
spacing: 12,
|
builder: (context, constraints) {
|
||||||
runSpacing: 12,
|
const spacing = 8.0;
|
||||||
|
final compactWidth = (constraints.maxWidth - spacing) / 2;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
children: metrics
|
children: metrics
|
||||||
.map((metric) => _MetricTile(data: metric))
|
.map(
|
||||||
|
(metric) => _MetricTile(
|
||||||
|
data: metric,
|
||||||
|
width:
|
||||||
|
(fieldSpans[metric.fieldKey] == 2 ||
|
||||||
|
metric.wide)
|
||||||
|
? constraints.maxWidth
|
||||||
|
: compactWidth,
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -418,10 +540,11 @@ class _SensorCard extends StatelessWidget {
|
|||||||
) {
|
) {
|
||||||
final items = <_MetricCardData>[];
|
final items = <_MetricCardData>[];
|
||||||
|
|
||||||
if (visibleMetrics.contains(SensorMetric.voltage) &&
|
if (visibleFields.contains('voltage') &&
|
||||||
telemetry.batteryMilliVolts != null) {
|
telemetry.batteryMilliVolts != null) {
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'voltage',
|
||||||
icon: Icons.bolt,
|
icon: Icons.bolt,
|
||||||
label: l10n.voltage,
|
label: l10n.voltage,
|
||||||
value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V',
|
value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V',
|
||||||
@@ -429,10 +552,11 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (visibleMetrics.contains(SensorMetric.battery) &&
|
if (visibleFields.contains('battery') &&
|
||||||
telemetry.batteryPercentage != null) {
|
telemetry.batteryPercentage != null) {
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'battery',
|
||||||
icon: Icons.battery_5_bar,
|
icon: Icons.battery_5_bar,
|
||||||
label: l10n.battery,
|
label: l10n.battery,
|
||||||
value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%',
|
value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%',
|
||||||
@@ -440,10 +564,11 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (visibleMetrics.contains(SensorMetric.temperature) &&
|
if (visibleFields.contains('temperature') &&
|
||||||
telemetry.temperature != null) {
|
telemetry.temperature != null) {
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'temperature',
|
||||||
icon: Icons.thermostat,
|
icon: Icons.thermostat,
|
||||||
label: l10n.temperature,
|
label: l10n.temperature,
|
||||||
value: '${telemetry.temperature!.toStringAsFixed(1)}°C',
|
value: '${telemetry.temperature!.toStringAsFixed(1)}°C',
|
||||||
@@ -451,10 +576,10 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (visibleMetrics.contains(SensorMetric.humidity) &&
|
if (visibleFields.contains('humidity') && telemetry.humidity != null) {
|
||||||
telemetry.humidity != null) {
|
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'humidity',
|
||||||
icon: Icons.water_drop,
|
icon: Icons.water_drop,
|
||||||
label: l10n.humidity,
|
label: l10n.humidity,
|
||||||
value: '${telemetry.humidity!.toStringAsFixed(1)}%',
|
value: '${telemetry.humidity!.toStringAsFixed(1)}%',
|
||||||
@@ -462,10 +587,10 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (visibleMetrics.contains(SensorMetric.pressure) &&
|
if (visibleFields.contains('pressure') && telemetry.pressure != null) {
|
||||||
telemetry.pressure != null) {
|
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'pressure',
|
||||||
icon: Icons.compress,
|
icon: Icons.compress,
|
||||||
label: l10n.pressure,
|
label: l10n.pressure,
|
||||||
value: '${telemetry.pressure!.toStringAsFixed(1)} hPa',
|
value: '${telemetry.pressure!.toStringAsFixed(1)} hPa',
|
||||||
@@ -473,35 +598,38 @@ class _SensorCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (visibleMetrics.contains(SensorMetric.gps) &&
|
if (visibleFields.contains('gps') && telemetry.gpsLocation != null) {
|
||||||
telemetry.gpsLocation != null) {
|
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: 'gps',
|
||||||
icon: Icons.place,
|
icon: Icons.place,
|
||||||
label: l10n.gpsTelemetry,
|
label: l10n.gpsTelemetry,
|
||||||
value:
|
value:
|
||||||
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
|
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
|
||||||
accent: const Color(0xFFAA3F57),
|
accent: const Color(0xFFAA3F57),
|
||||||
wide: true,
|
wide: true,
|
||||||
|
mapLocation: LatLng(
|
||||||
|
telemetry.gpsLocation!.latitude,
|
||||||
|
telemetry.gpsLocation!.longitude,
|
||||||
|
),
|
||||||
|
secondaryValue: formatPlusCode(
|
||||||
|
telemetry.gpsLocation!.latitude,
|
||||||
|
telemetry.gpsLocation!.longitude,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
if (visibleMetrics.contains(SensorMetric.updated)) {
|
|
||||||
items.add(
|
|
||||||
_MetricCardData(
|
|
||||||
icon: Icons.update,
|
|
||||||
label: l10n.updated,
|
|
||||||
value: _formatTelemetryTime(telemetry.timestamp),
|
|
||||||
accent: const Color(0xFF6C727F),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (telemetry.extraSensorData != null) {
|
if (telemetry.extraSensorData != null) {
|
||||||
for (final entry in telemetry.extraSensorData!.entries) {
|
for (final entry in telemetry.extraSensorData!.entries) {
|
||||||
|
final fieldKey = _extraFieldKey(entry.key);
|
||||||
|
if (!visibleFields.contains(fieldKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
items.add(
|
items.add(
|
||||||
_MetricCardData(
|
_MetricCardData(
|
||||||
|
fieldKey: fieldKey,
|
||||||
icon: Icons.sensors,
|
icon: Icons.sensors,
|
||||||
label: entry.key,
|
label: _formatExtraFieldLabel(entry.key),
|
||||||
value: '${entry.value}',
|
value: '${entry.value}',
|
||||||
accent: const Color(0xFF3E657C),
|
accent: const Color(0xFF3E657C),
|
||||||
),
|
),
|
||||||
@@ -514,7 +642,7 @@ class _SensorCard extends StatelessWidget {
|
|||||||
|
|
||||||
String _formatTelemetryTime(DateTime timestamp) {
|
String _formatTelemetryTime(DateTime timestamp) {
|
||||||
final diff = DateTime.now().difference(timestamp);
|
final diff = DateTime.now().difference(timestamp);
|
||||||
if (diff.inMinutes < 1) return 'just now';
|
if (diff.inMinutes < 1) return 'now';
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
return '${diff.inDays}d ago';
|
return '${diff.inDays}d ago';
|
||||||
@@ -531,81 +659,48 @@ class _SensorCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatusPill extends StatelessWidget {
|
class _InlineStateMeta extends StatelessWidget {
|
||||||
final SensorRefreshState state;
|
|
||||||
|
|
||||||
const _StatusPill({required this.state});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final (label, color, icon) = switch (state) {
|
|
||||||
SensorRefreshState.idle => (
|
|
||||||
'Idle',
|
|
||||||
const Color(0xFF5D7185),
|
|
||||||
Icons.sensors,
|
|
||||||
),
|
|
||||||
SensorRefreshState.refreshing => (
|
|
||||||
'Refreshing',
|
|
||||||
const Color(0xFF266AC2),
|
|
||||||
Icons.sync,
|
|
||||||
),
|
|
||||||
SensorRefreshState.success => (
|
|
||||||
'Updated',
|
|
||||||
const Color(0xFF218B63),
|
|
||||||
Icons.check_circle,
|
|
||||||
),
|
|
||||||
SensorRefreshState.timeout => (
|
|
||||||
'No response',
|
|
||||||
const Color(0xFFC17B1D),
|
|
||||||
Icons.schedule,
|
|
||||||
),
|
|
||||||
SensorRefreshState.unavailable => (
|
|
||||||
'Unavailable',
|
|
||||||
const Color(0xFFB13B55),
|
|
||||||
Icons.error_outline,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return _InfoPill(
|
|
||||||
icon: icon,
|
|
||||||
label: label,
|
|
||||||
foreground: color,
|
|
||||||
background: color.withValues(alpha: 0.10),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoPill extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
final String label;
|
||||||
final Color? foreground;
|
final Color color;
|
||||||
final Color? background;
|
final IconData? icon;
|
||||||
|
final bool spinning;
|
||||||
|
|
||||||
const _InfoPill({
|
const _InlineStateMeta({
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
required this.label,
|
||||||
this.foreground,
|
required this.color,
|
||||||
this.background,
|
this.icon,
|
||||||
|
this.spinning = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = foreground ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: color.withValues(alpha: 0.10),
|
||||||
background ?? Theme.of(context).colorScheme.surfaceContainerHighest,
|
borderRadius: BorderRadius.circular(999),
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: color),
|
if (spinning)
|
||||||
const SizedBox(width: 8),
|
SizedBox(
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 1.7,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (icon != null)
|
||||||
|
Icon(icon, size: 11, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -639,55 +734,193 @@ class _InlineAlertBadge extends StatelessWidget {
|
|||||||
|
|
||||||
class _MetricTile extends StatelessWidget {
|
class _MetricTile extends StatelessWidget {
|
||||||
final _MetricCardData data;
|
final _MetricCardData data;
|
||||||
|
final double width;
|
||||||
|
|
||||||
const _MetricTile({required this.data});
|
const _MetricTile({required this.data, required this.width});
|
||||||
|
|
||||||
|
Future<void> _showExpandedMap(BuildContext context) async {
|
||||||
|
final location = data.mapLocation;
|
||||||
|
if (location == null) return;
|
||||||
|
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (pageContext) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(data.label),
|
||||||
|
Text(
|
||||||
|
data.value,
|
||||||
|
style: Theme.of(pageContext).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (data.secondaryValue != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Text(
|
||||||
|
data.secondaryValue!,
|
||||||
|
style: Theme.of(pageContext).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: location,
|
||||||
|
initialZoom: 15,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName:
|
||||||
|
'com.meshcore.sar.meshcore_sar_app',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: location,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: data.accent,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final width = data.wide ? 320.0 : 168.0;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: width,
|
width: width,
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: data.accent.withValues(alpha: 0.08),
|
color: data.accent.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: data.mapLocation == null
|
||||||
|
? Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_MetricIcon(accent: data.accent, icon: data.icon),
|
||||||
width: 36,
|
const SizedBox(width: 10),
|
||||||
height: 36,
|
Expanded(child: _MetricText(data: data)),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: data.accent.withValues(alpha: 0.14),
|
)
|
||||||
borderRadius: BorderRadius.circular(12),
|
: Column(
|
||||||
),
|
|
||||||
child: Icon(data.icon, color: data.accent, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
data.label,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
children: [
|
||||||
|
_MetricIcon(accent: data.accent, icon: data.icon),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: _MetricText(data: data)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: () => _showExpandedMap(context),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 104,
|
||||||
|
width: double.infinity,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
flutter_map.FlutterMap(
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: data.mapLocation!,
|
||||||
|
initialZoom: 14,
|
||||||
|
interactionOptions:
|
||||||
|
const flutter_map.InteractionOptions(
|
||||||
|
flags: flutter_map.InteractiveFlag.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate:
|
||||||
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName:
|
||||||
|
'com.meshcore.sar.meshcore_sar_app',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(
|
||||||
|
markers: [
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: data.mapLocation!,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: Icon(
|
||||||
|
Icons.location_on,
|
||||||
color: data.accent,
|
color: data.accent,
|
||||||
fontWeight: FontWeight.w700,
|
size: 28,
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
data.value,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
height: 1.1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.open_in_full,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Open map',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -695,33 +928,180 @@ class _MetricTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MetricIcon extends StatelessWidget {
|
||||||
|
final Color accent;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _MetricIcon({required this.accent, required this.icon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: accent, size: 18),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricText extends StatelessWidget {
|
||||||
|
final _MetricCardData data;
|
||||||
|
|
||||||
|
const _MetricText({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: data.accent,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
data.value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (data.secondaryValue != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
data.secondaryValue!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MetricCardData {
|
class _MetricCardData {
|
||||||
|
final String fieldKey;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
|
final String? secondaryValue;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final bool wide;
|
final bool wide;
|
||||||
|
final LatLng? mapLocation;
|
||||||
|
|
||||||
const _MetricCardData({
|
const _MetricCardData({
|
||||||
|
required this.fieldKey,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
this.secondaryValue,
|
||||||
required this.accent,
|
required this.accent,
|
||||||
this.wide = false,
|
this.wide = false,
|
||||||
|
this.mapLocation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _metricLabel(SensorMetric metric) {
|
class _FieldOption {
|
||||||
return switch (metric) {
|
final String key;
|
||||||
SensorMetric.lastSeen => 'Last seen',
|
final String label;
|
||||||
SensorMetric.voltage => 'Voltage',
|
|
||||||
SensorMetric.battery => 'Battery',
|
const _FieldOption({required this.key, required this.label});
|
||||||
SensorMetric.temperature => 'Temperature',
|
}
|
||||||
SensorMetric.humidity => 'Humidity',
|
|
||||||
SensorMetric.pressure => 'Pressure',
|
List<_FieldOption> _fieldOptionsFor(Contact? contact) {
|
||||||
SensorMetric.gps => 'GPS',
|
final telemetry = contact?.telemetry;
|
||||||
SensorMetric.updated => 'Updated',
|
final options = <_FieldOption>[
|
||||||
|
if (telemetry?.batteryMilliVolts != null)
|
||||||
|
const _FieldOption(key: 'voltage', label: 'Voltage'),
|
||||||
|
if (telemetry?.batteryPercentage != null)
|
||||||
|
const _FieldOption(key: 'battery', label: 'Battery'),
|
||||||
|
if (telemetry?.temperature != null)
|
||||||
|
const _FieldOption(key: 'temperature', label: 'Temperature'),
|
||||||
|
if (telemetry?.humidity != null)
|
||||||
|
const _FieldOption(key: 'humidity', label: 'Humidity'),
|
||||||
|
if (telemetry?.pressure != null)
|
||||||
|
const _FieldOption(key: 'pressure', label: 'Pressure'),
|
||||||
|
if (telemetry?.gpsLocation != null)
|
||||||
|
const _FieldOption(key: 'gps', label: 'GPS'),
|
||||||
|
];
|
||||||
|
|
||||||
|
final extraSensorData = telemetry?.extraSensorData;
|
||||||
|
if (extraSensorData != null) {
|
||||||
|
for (final key in extraSensorData.keys) {
|
||||||
|
options.add(
|
||||||
|
_FieldOption(
|
||||||
|
key: _extraFieldKey(key),
|
||||||
|
label: _formatExtraFieldLabel(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extraFieldKey(String label) {
|
||||||
|
return 'extra:$label';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatExtraFieldLabel(String rawKey) {
|
||||||
|
final knownPrefixes = <String, String>{
|
||||||
|
'altitude': 'Altitude',
|
||||||
|
'illuminance': 'Illuminance',
|
||||||
|
'presence': 'Presence',
|
||||||
|
'digital_input': 'Digital input',
|
||||||
|
'digital_output': 'Digital output',
|
||||||
|
'analog_input': 'Analog input',
|
||||||
|
'analog_output': 'Analog output',
|
||||||
|
'accelerometer': 'Accelerometer',
|
||||||
|
'gyrometer': 'Gyrometer',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (final entry in knownPrefixes.entries) {
|
||||||
|
final prefix = '${entry.key}_';
|
||||||
|
if (rawKey == entry.key) {
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
if (rawKey.startsWith(prefix)) {
|
||||||
|
final suffix = rawKey.substring(prefix.length);
|
||||||
|
final channel = int.tryParse(suffix);
|
||||||
|
if (channel != null) {
|
||||||
|
return '${entry.value} (ch $channel)';
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = rawKey.split('_');
|
||||||
|
if (parts.isEmpty) return rawKey;
|
||||||
|
final channel = parts.length > 1 ? parts.last : null;
|
||||||
|
final base = parts.length > 1
|
||||||
|
? parts.sublist(0, parts.length - 1).join(' ')
|
||||||
|
: rawKey;
|
||||||
|
final title = base
|
||||||
|
.split(' ')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) => '${part[0].toUpperCase()}${part.substring(1)}')
|
||||||
|
.join(' ');
|
||||||
|
if (channel != null && int.tryParse(channel) != null) {
|
||||||
|
return '$title (ch $channel)';
|
||||||
|
}
|
||||||
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _typeIcon(Contact contact) {
|
IconData _typeIcon(Contact contact) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:io' show Platform;
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_avif/flutter_avif.dart';
|
import 'package:flutter_avif/flutter_avif.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -15,11 +16,13 @@ import '../providers/app_provider.dart';
|
|||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/update_checker_service.dart';
|
import '../services/update_checker_service.dart';
|
||||||
import '../services/voice_codec_service.dart';
|
|
||||||
import '../services/voice_bitrate_preferences.dart';
|
import '../services/voice_bitrate_preferences.dart';
|
||||||
import '../services/image_preferences.dart';
|
import '../services/image_preferences.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../services/image_codec_service.dart';
|
import '../services/image_codec_service.dart';
|
||||||
|
import '../services/developer_mode_service.dart';
|
||||||
import '../utils/sample_data_generator.dart';
|
import '../utils/sample_data_generator.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
@@ -56,6 +59,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isCheckingForUpdates = false;
|
bool _isCheckingForUpdates = false;
|
||||||
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||||
|
int _routeHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
||||||
int _imageCompression = ImagePreferences.defaultQuality;
|
int _imageCompression = ImagePreferences.defaultQuality;
|
||||||
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
||||||
@@ -65,6 +69,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Uint8List? _previewCompressedBytes;
|
Uint8List? _previewCompressedBytes;
|
||||||
bool _isPreviewLoading = false;
|
bool _isPreviewLoading = false;
|
||||||
bool _showCurrentImagePreview = true;
|
bool _showCurrentImagePreview = true;
|
||||||
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
|
bool _isDeveloperModeEnabled = false;
|
||||||
|
DateTime? _onlineTraceCacheUpdatedAt;
|
||||||
|
bool _isClearingOnlineTraceCache = false;
|
||||||
|
int _versionTapCount = 0;
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
final LocationTrackingService _locationService = LocationTrackingService();
|
final LocationTrackingService _locationService = LocationTrackingService();
|
||||||
|
|
||||||
@@ -76,8 +87,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_loadPackageInfo();
|
_loadPackageInfo();
|
||||||
_initializeLocationService();
|
_initializeLocationService();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadVoiceBitratePreference();
|
_loadVoicePreferences();
|
||||||
|
_loadRouteHashSizePreference();
|
||||||
_loadImagePreferences();
|
_loadImagePreferences();
|
||||||
|
_loadFastLocationSettings();
|
||||||
|
_loadDeveloperMode();
|
||||||
|
_loadOnlineTraceCacheStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -107,12 +122,62 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadDeveloperMode() async {
|
||||||
|
final isEnabled = await DeveloperModeService.isEnabled();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = isEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOnlineTraceCacheStatus() async {
|
||||||
|
final cachedAt = await MeshMapNodesService.cachedAt();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineTraceCacheUpdatedAt = cachedAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleVersionTap() async {
|
||||||
|
if (_isDeveloperModeEnabled) {
|
||||||
|
await DeveloperModeService.setEnabled(false);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = false;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextTapCount = _versionTapCount + 1;
|
||||||
|
if (nextTapCount >= 3) {
|
||||||
|
await DeveloperModeService.setEnabled(true);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isDeveloperModeEnabled = true;
|
||||||
|
_versionTapCount = 0;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_versionTapCount = nextTapCount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveRxTxPreference(bool value) async {
|
Future<void> _saveRxTxPreference(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('show_rx_tx_indicators', value);
|
await prefs.setBool('show_rx_tx_indicators', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadVoiceBitratePreference() async {
|
Future<void> _loadVoicePreferences() async {
|
||||||
final value = await VoiceBitratePreferences.getBitrate();
|
final value = await VoiceBitratePreferences.getBitrate();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -132,6 +197,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return '$bitrate bps';
|
return '$bitrate bps';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRouteHashSizePreference() async {
|
||||||
|
final value = await RouteHashPreferences.getHashSize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_routeHashSize = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveRouteHashSizePreference(int value) async {
|
||||||
|
await RouteHashPreferences.setHashSize(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_routeHashSize = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadImagePreferences() async {
|
Future<void> _loadImagePreferences() async {
|
||||||
final size = await ImagePreferences.getMaxSize();
|
final size = await ImagePreferences.getMaxSize();
|
||||||
final compression = await ImagePreferences.getCompression();
|
final compression = await ImagePreferences.getCompression();
|
||||||
@@ -150,6 +231,108 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _refreshImageModePreview();
|
await _refreshImageModePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFastLocationSettings() async {
|
||||||
|
await _locationService.loadSettings();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
||||||
|
_fastLocationMovementThresholdMeters =
|
||||||
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
|
_fastLocationActiveCadenceSeconds =
|
||||||
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
|
await _locationService.setFastLocationUpdatesEnabled(enabled);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationUpdatesEnabled = _locationService.fastLocationUpdatesEnabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editFastLocationMovementThreshold() async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: _fastLocationMovementThresholdMeters.toStringAsFixed(0),
|
||||||
|
);
|
||||||
|
final value = await showDialog<double>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Fast GPS movement threshold'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Meters',
|
||||||
|
helperText: 'Valid range: 1 to 1000 meters',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final parsed = double.tryParse(controller.text.trim());
|
||||||
|
if (parsed == null) return;
|
||||||
|
Navigator.pop(context, parsed.clamp(1.0, 1000.0));
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value == null) return;
|
||||||
|
await _locationService.updateFastLocationMovementThreshold(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationMovementThresholdMeters =
|
||||||
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editFastLocationActiveCadence() async {
|
||||||
|
final controller = TextEditingController(
|
||||||
|
text: _fastLocationActiveCadenceSeconds.toString(),
|
||||||
|
);
|
||||||
|
final value = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Fast GPS active-use interval'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Seconds',
|
||||||
|
helperText: 'Valid range: 5 to 60 seconds',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final parsed = int.tryParse(controller.text.trim());
|
||||||
|
if (parsed == null) return;
|
||||||
|
Navigator.pop(context, parsed.clamp(5, 60));
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (value == null) return;
|
||||||
|
await _locationService.updateFastLocationActiveCadenceSeconds(value);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_fastLocationActiveCadenceSeconds =
|
||||||
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveImageMaxSize(int size) async {
|
Future<void> _saveImageMaxSize(int size) async {
|
||||||
await ImagePreferences.setMaxSize(size);
|
await ImagePreferences.setMaxSize(size);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -299,7 +482,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
/// Check for app updates and show notification or dialog
|
/// Check for app updates and show notification or dialog
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only on Android
|
// Only on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -658,14 +841,115 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _clearOnlineTraceCache() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear online trace database'),
|
||||||
|
content: const Text(
|
||||||
|
'This removes the cached online node database used as a trace fallback.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(AppLocalizations.of(context)!.clear),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingOnlineTraceCache = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await MeshMapNodesService.clearCache();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineTraceCacheUpdatedAt = null;
|
||||||
|
_isClearingOnlineTraceCache = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Online trace database cleared'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _onlineTraceCacheSubtitle() {
|
||||||
|
final cachedAt = _onlineTraceCacheUpdatedAt;
|
||||||
|
if (cachedAt == null) {
|
||||||
|
return 'No cached online database. Refresh runs in background when internet is available.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
|
||||||
|
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime value) {
|
||||||
|
final local = value.toLocal();
|
||||||
|
String two(int part) => part.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showRouteHashSizeDialog() async {
|
||||||
|
final selected = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SimpleDialog(
|
||||||
|
title: const Text('Route path byte size'),
|
||||||
|
children: [
|
||||||
|
for (final value in [1, 2, 3])
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () => Navigator.of(context).pop(value),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('$value byte${value == 1 ? '' : 's'}'),
|
||||||
|
Text(
|
||||||
|
'Use ${value * 2} hex characters per hop',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_routeHashSize == value)
|
||||||
|
Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected == null) return;
|
||||||
|
await _saveRouteHashSizePreference(selected);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||||
children: [
|
children: [
|
||||||
// General Settings Section
|
_buildSectionHeader('Appearance'),
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.general),
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.palette),
|
leading: const Icon(Icons.palette),
|
||||||
title: Text(AppLocalizations.of(context)!.theme),
|
title: Text(AppLocalizations.of(context)!.theme),
|
||||||
@@ -673,10 +957,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showThemeDialog(),
|
onTap: () => _showThemeDialog(),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.language),
|
||||||
|
title: Text(AppLocalizations.of(context)!.language),
|
||||||
|
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _showLanguageDialog(),
|
||||||
|
),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
secondary: const Icon(Icons.radar),
|
secondary: const Icon(Icons.radar),
|
||||||
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
|
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
|
||||||
subtitle: Text(AppLocalizations.of(context)!.displayPacketActivity),
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.displayPacketActivity,
|
||||||
|
),
|
||||||
value: _showRxTxIndicators,
|
value: _showRxTxIndicators,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -685,32 +978,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _saveRxTxPreference(value);
|
await _saveRxTxPreference(value);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Consumer<AppProvider>(
|
]),
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.visibility_off),
|
_buildSectionHeader('Navigation'),
|
||||||
title: Text(AppLocalizations.of(context)!.simpleMode),
|
_buildSettingsCard([
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(context)!.simpleModeDescription,
|
|
||||||
),
|
|
||||||
value: appProvider.isSimpleMode,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleSimpleMode(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.person_add_alt_1),
|
|
||||||
title: const Text('Auto-add discovered contacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Automatically fetch and add new contacts when they are discovered',
|
|
||||||
),
|
|
||||||
value: appProvider.autoAddDiscoveredContacts,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.map_outlined),
|
secondary: const Icon(Icons.map_outlined),
|
||||||
@@ -750,12 +1021,70 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Messaging'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.person_add_alt_1),
|
||||||
|
title: const Text('Auto-add discovered contacts'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Automatically fetch and add new contacts when they are discovered',
|
||||||
|
),
|
||||||
|
value: appProvider.autoAddDiscoveredContacts,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.language),
|
leading: const Icon(Icons.alt_route),
|
||||||
title: Text(AppLocalizations.of(context)!.language),
|
title: const Text('Route path byte size'),
|
||||||
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
|
subtitle: Text(
|
||||||
|
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _showLanguageDialog(),
|
onTap: _showRouteHashSizeDialog,
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.swap_horiz),
|
||||||
|
title: const Text('Auto route rotation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Rotate between best known direct paths and flood mode for room/contact sends',
|
||||||
|
),
|
||||||
|
value: appProvider.autoRouteRotationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleAutoRouteRotationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: const Text('Nearest repeater fallback'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'After normal retries fail, try one final resend through the nearest repeater',
|
||||||
|
),
|
||||||
|
value: appProvider.nearestRelayFallbackEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleNearestRelayFallbackEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.route),
|
||||||
|
title: const Text('Clear path on max retry'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Clear the route only after all retries and final router fallback fail',
|
||||||
|
),
|
||||||
|
value: appProvider.clearPathOnMaxRetry,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleClearPathOnMaxRetry(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||||
@@ -766,9 +1095,65 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
subtitle: const Text('Delete all stored message history'),
|
subtitle: const Text('Delete all stored message history'),
|
||||||
onTap: _clearMessages,
|
onTap: _clearMessages,
|
||||||
),
|
),
|
||||||
const Divider(),
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => ListTile(
|
||||||
|
leading: const Icon(Icons.format_size),
|
||||||
|
title: const Text('Message font size'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${(appProvider.messageFontScale * 100).round()}% of default',
|
||||||
|
),
|
||||||
|
trailing: SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: Slider(
|
||||||
|
value: appProvider.messageFontScale,
|
||||||
|
min: 0.85,
|
||||||
|
max: 1.4,
|
||||||
|
divisions: 11,
|
||||||
|
label: '${(appProvider.messageFontScale * 100).round()}%',
|
||||||
|
onChanged: (value) {
|
||||||
|
appProvider.setMessageFontScale(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
_buildSectionHeader('Tracing'),
|
||||||
|
_buildSettingsCard([
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_sync),
|
||||||
|
title: const Text('Online trace database'),
|
||||||
|
subtitle: Text(_onlineTraceCacheSubtitle()),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.delete_sweep,
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Clear online trace database',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _isClearingOnlineTraceCache ? null : Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Remove the 24-hour cached fallback used when local route matches are incomplete',
|
||||||
|
),
|
||||||
|
enabled: !_isClearingOnlineTraceCache,
|
||||||
|
trailing: _isClearingOnlineTraceCache
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: _isClearingOnlineTraceCache
|
||||||
|
? null
|
||||||
|
: _clearOnlineTraceCache,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
// Voice Settings Section
|
|
||||||
_buildSectionHeader('Voice'),
|
_buildSectionHeader('Voice'),
|
||||||
Consumer2<AppProvider, ConnectionProvider>(
|
Consumer2<AppProvider, ConnectionProvider>(
|
||||||
builder: (context, appProvider, connectionProvider, child) =>
|
builder: (context, appProvider, connectionProvider, child) =>
|
||||||
@@ -778,9 +1163,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
compressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||||
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
limiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||||
|
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
echoCancellationEnabled:
|
||||||
|
appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
noiseSuppressionEnabled:
|
||||||
|
appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.graphic_eq),
|
leading: const Icon(Icons.graphic_eq),
|
||||||
title: const Text('Voice bitrate'),
|
title: const Text('Voice bitrate'),
|
||||||
@@ -823,6 +1214,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.auto_fix_high),
|
||||||
|
title: const Text('Mic auto gain'),
|
||||||
|
subtitle: const Text('Lets the recorder adjust input level'),
|
||||||
|
value: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceAutoGainEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.hearing_disabled),
|
||||||
|
title: const Text('Echo cancellation'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder echo cancellation if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceEchoCancellationEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Consumer<AppProvider>(
|
||||||
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.noise_control_off),
|
||||||
|
title: const Text('Noise suppression'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Uses recorder noise suppression if available',
|
||||||
|
),
|
||||||
|
value: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
|
onChanged: (value) async {
|
||||||
|
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
Consumer<AppProvider>(
|
Consumer<AppProvider>(
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
builder: (context, appProvider, child) => SwitchListTile(
|
||||||
secondary: const Icon(Icons.content_cut),
|
secondary: const Icon(Icons.content_cut),
|
||||||
@@ -836,10 +1264,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// Image Settings Section
|
_buildSectionHeader('Images'),
|
||||||
_buildSectionHeader('Image'),
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.photo_size_select_large),
|
leading: const Icon(Icons.photo_size_select_large),
|
||||||
title: const Text('Max image size'),
|
title: const Text('Max image size'),
|
||||||
@@ -850,10 +1278,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.tune),
|
leading: const Icon(Icons.tune),
|
||||||
title: const Text('Image compression'),
|
title: const Text('Image compression'),
|
||||||
subtitle: Text('$_imageCompression / 90 (higher = smaller file)'),
|
subtitle: Text(
|
||||||
|
'$_imageCompression / 90 (higher = smaller file)',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: _imageCompression.toDouble(),
|
value: _imageCompression.toDouble(),
|
||||||
min: 10,
|
min: 10,
|
||||||
@@ -892,14 +1322,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
await _refreshImageModePreview();
|
await _refreshImageModePreview();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
Consumer<ConnectionProvider>(
|
Consumer<ConnectionProvider>(
|
||||||
builder: (context, connectionProvider, child) =>
|
builder: (context, connectionProvider, child) =>
|
||||||
_buildImageModePreviewCard(connectionProvider),
|
_buildImageModePreviewCard(connectionProvider),
|
||||||
),
|
),
|
||||||
const Divider(),
|
_buildSectionHeader('Templates & Help'),
|
||||||
|
_buildSettingsCard([
|
||||||
// Templates Section
|
|
||||||
_buildSectionHeader('Templates'),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_searching),
|
leading: const Icon(Icons.location_searching),
|
||||||
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
title: Text(AppLocalizations.of(context)!.sarTemplates),
|
||||||
@@ -919,13 +1348,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
|
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
// Show wizard without resetting state - just as a modal
|
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => WelcomeWizardScreen(
|
builder: (context) => WelcomeWizardScreen(
|
||||||
onCompleted: () {
|
onCompleted: () {
|
||||||
// Just pop back to settings when done
|
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -933,14 +1360,40 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// Network Sharing Section
|
if (!kIsWeb) ...[
|
||||||
|
_buildSectionHeader('Network Sharing'),
|
||||||
const ConnectionModeSelector(),
|
const ConnectionModeSelector(),
|
||||||
const Divider(),
|
],
|
||||||
|
|
||||||
// Permissions Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
|
||||||
|
_buildSettingsCard([
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(Icons.gps_fixed),
|
||||||
|
title: const Text('Fast private GPS updates'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Use private zero-hop updates while moving significantly or while actively using map/messages.',
|
||||||
|
),
|
||||||
|
value: _fastLocationUpdatesEnabled,
|
||||||
|
onChanged: _setFastLocationUpdatesEnabled,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.straighten),
|
||||||
|
title: const Text('Movement threshold'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _editFastLocationMovementThreshold,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.timer),
|
||||||
|
title: const Text('Active-use update interval'),
|
||||||
|
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _editFastLocationActiveCadence,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.location_on),
|
leading: const Icon(Icons.location_on),
|
||||||
title: Text(AppLocalizations.of(context)!.locationPermission),
|
title: Text(AppLocalizations.of(context)!.locationPermission),
|
||||||
@@ -990,10 +1443,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => _handleLocationPermissionTap(),
|
onTap: () => _handleLocationPermissionTap(),
|
||||||
),
|
),
|
||||||
const Divider(),
|
]),
|
||||||
|
|
||||||
// About Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.about),
|
_buildSectionHeader(AppLocalizations.of(context)!.about),
|
||||||
|
_buildSettingsCard([
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info),
|
leading: const Icon(Icons.info),
|
||||||
title: Text(AppLocalizations.of(context)!.appVersion),
|
title: Text(AppLocalizations.of(context)!.appVersion),
|
||||||
@@ -1002,11 +1455,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
|
||||||
: 'Loading...',
|
: 'Loading...',
|
||||||
),
|
),
|
||||||
|
onTap: _handleVersionTap,
|
||||||
),
|
),
|
||||||
// Check for Updates button (Android only)
|
ListTile(
|
||||||
if (Platform.isAndroid)
|
leading: const Icon(Icons.badge),
|
||||||
|
title: Text(AppLocalizations.of(context)!.appName),
|
||||||
|
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.description),
|
||||||
|
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
|
||||||
|
subtitle: Text(
|
||||||
|
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
|
||||||
|
),
|
||||||
|
onTap: () => _showAboutDialog(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
|
onPressed: _isCheckingForUpdates ? null : _checkForUpdates,
|
||||||
icon: _isCheckingForUpdates
|
icon: _isCheckingForUpdates
|
||||||
@@ -1027,34 +1494,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
_buildSectionHeader('Developer & Data'),
|
||||||
leading: const Icon(Icons.badge),
|
_buildSettingsCard([
|
||||||
title: Text(AppLocalizations.of(context)!.appName),
|
|
||||||
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.description),
|
|
||||||
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
|
|
||||||
subtitle: Text(
|
|
||||||
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
|
|
||||||
),
|
|
||||||
onTap: () => _showAboutDialog(),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// Developer Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.developer),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.bug_report),
|
leading: const Icon(Icons.bug_report),
|
||||||
title: Text(AppLocalizations.of(context)!.packageName),
|
title: Text(AppLocalizations.of(context)!.packageName),
|
||||||
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
|
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
|
||||||
),
|
),
|
||||||
const Divider(),
|
|
||||||
|
|
||||||
// Sample Data Section
|
|
||||||
_buildSectionHeader(AppLocalizations.of(context)!.sampleData),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context)!.sampleData,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context)!.sampleDataDescription,
|
AppLocalizations.of(context)!.sampleDataDescription,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
@@ -1065,7 +1520,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1099,6 +1554,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1106,7 +1562,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
Widget _buildSectionHeader(String title) {
|
Widget _buildSectionHeader(String title) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
@@ -1117,6 +1573,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSettingsCard(List<Widget> children) {
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: ListTile.divideTiles(
|
||||||
|
context: context,
|
||||||
|
tiles: children,
|
||||||
|
color: Theme.of(context).dividerColor.withValues(alpha: 0.4),
|
||||||
|
).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
|
Widget _buildImageModePreviewCard(ConnectionProvider connectionProvider) {
|
||||||
final sourceBytes = _previewSourceBytes;
|
final sourceBytes = _previewSourceBytes;
|
||||||
final fileName = _previewSourceName ?? 'No image selected';
|
final fileName = _previewSourceName ?? 'No image selected';
|
||||||
@@ -1301,6 +1770,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
required bool bandPassEnabled,
|
required bool bandPassEnabled,
|
||||||
required bool compressorEnabled,
|
required bool compressorEnabled,
|
||||||
required bool limiterEnabled,
|
required bool limiterEnabled,
|
||||||
|
required bool autoGainEnabled,
|
||||||
|
required bool echoCancellationEnabled,
|
||||||
|
required bool noiseSuppressionEnabled,
|
||||||
required bool silenceTrimEnabled,
|
required bool silenceTrimEnabled,
|
||||||
}) {
|
}) {
|
||||||
final supported = VoiceBitratePreferences.supportedBitrates;
|
final supported = VoiceBitratePreferences.supportedBitrates;
|
||||||
@@ -1313,6 +1785,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
(bandPassEnabled ? 1 : 0) +
|
(bandPassEnabled ? 1 : 0) +
|
||||||
(compressorEnabled ? 1 : 0) +
|
(compressorEnabled ? 1 : 0) +
|
||||||
(limiterEnabled ? 1 : 0) +
|
(limiterEnabled ? 1 : 0) +
|
||||||
|
(autoGainEnabled ? 1 : 0) +
|
||||||
|
(echoCancellationEnabled ? 1 : 0) +
|
||||||
|
(noiseSuppressionEnabled ? 1 : 0) +
|
||||||
(silenceTrimEnabled ? 1 : 0);
|
(silenceTrimEnabled ? 1 : 0);
|
||||||
final radioBw = connectionProvider.deviceInfo.radioBw;
|
final radioBw = connectionProvider.deviceInfo.radioBw;
|
||||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||||
@@ -1320,7 +1795,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
final bwHz = _resolveBandwidthHz(radioBw);
|
final bwHz = _resolveBandwidthHz(radioBw);
|
||||||
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
|
||||||
const voicePreviewMs = 10000; // 10-second reference clip
|
const voicePreviewMs = 10000; // 10-second reference clip
|
||||||
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
|
final packetDurationMs = voiceMode.packetDurationMs;
|
||||||
final voicePacketCount =
|
final voicePacketCount =
|
||||||
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
|
||||||
final voiceDirect = estimateVoiceTransmitDuration(
|
final voiceDirect = estimateVoiceTransmitDuration(
|
||||||
@@ -1355,7 +1830,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Bitrate: $bitrate bps',
|
'Codec: ${voiceMode.label} · $bitrate bps',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -1402,6 +1877,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Auto gain',
|
||||||
|
enabled: autoGainEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Echo cancel',
|
||||||
|
enabled: echoCancellationEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _voiceStatChip(
|
||||||
|
label: 'Noise suppress',
|
||||||
|
enabled: noiseSuppressionEnabled,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1414,7 +1914,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Processing enabled: $enabledCount/4',
|
'Processing enabled: $enabledCount/7',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/wizard_preferences.dart';
|
import '../services/wizard_preferences.dart';
|
||||||
|
import '../widgets/connection_dialog.dart';
|
||||||
|
|
||||||
/// Welcome wizard screen to introduce new users to the app
|
/// Welcome wizard screen to introduce new users to the app
|
||||||
class WelcomeWizardScreen extends StatefulWidget {
|
class WelcomeWizardScreen extends StatefulWidget {
|
||||||
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
|
final TextEditingController _deviceNameController = TextEditingController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
|
bool _isApplyingDeviceSetup = false;
|
||||||
static const int _totalPages = 6;
|
static const int _totalPages = 6;
|
||||||
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
|
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia',
|
||||||
|
label: 'Australia',
|
||||||
|
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
|
||||||
|
frequencyKhz: 915800,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 10,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_narrow',
|
||||||
|
label: 'Australia (Narrow)',
|
||||||
|
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
|
||||||
|
frequencyKhz: 916575,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_sa_wa',
|
||||||
|
label: 'Australia: SA, WA',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_qld',
|
||||||
|
label: 'Australia: QLD',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_narrow',
|
||||||
|
label: 'EU/UK (Narrow)',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_deprecated',
|
||||||
|
label: 'EU/UK (Deprecated)',
|
||||||
|
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 869525,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'czech_republic_narrow',
|
||||||
|
label: 'Czech Republic (Narrow)',
|
||||||
|
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 869432,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_433_long_range',
|
||||||
|
label: 'EU 433MHz (Long Range)',
|
||||||
|
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 433650,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand',
|
||||||
|
label: 'New Zealand',
|
||||||
|
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand_narrow',
|
||||||
|
label: 'New Zealand (Narrow)',
|
||||||
|
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_433',
|
||||||
|
label: 'Portugal 433',
|
||||||
|
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
|
||||||
|
frequencyKhz: 433375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 9,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_868',
|
||||||
|
label: 'Portugal 868',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'switzerland',
|
||||||
|
label: 'Switzerland',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'usa_canada_recommended',
|
||||||
|
label: 'USA/Canada (Recommended)',
|
||||||
|
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 910525,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_narrow',
|
||||||
|
label: 'Vietnam (Narrow)',
|
||||||
|
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_deprecated',
|
||||||
|
label: 'Vietnam (Deprecated)',
|
||||||
|
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
_RadioPreset _selectedPreset = _radioPresets[4];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
||||||
|
_deviceNameController.text =
|
||||||
|
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
|
_deviceNameController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
if (_currentPage < _totalPages - 1) {
|
if (_currentPage == 1) {
|
||||||
|
_handleDeviceSetupAction();
|
||||||
|
} else if (_currentPage < _totalPages - 1) {
|
||||||
_pageController.nextPage(
|
_pageController.nextPage(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openConnectionDialog() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const ConnectionDialog(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||||
|
if (fetchedName.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_deviceNameController.text = fetchedName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDeviceSetupAction() async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
if (!deviceInfo.isConnected) {
|
||||||
|
await _openConnectionDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedName = _deviceNameController.text.trim();
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isApplyingDeviceSetup = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.setAdvertName(trimmedName);
|
||||||
|
await connectionProvider.setRadioParams(
|
||||||
|
frequency: _selectedPreset.frequencyKhz,
|
||||||
|
bandwidth: _selectedPreset.bandwidth,
|
||||||
|
spreadingFactor: _selectedPreset.spreadingFactor,
|
||||||
|
codingRate: _selectedPreset.codingRate,
|
||||||
|
);
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.failedToSave(e.toString())),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isApplyingDeviceSetup = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skipDeviceSetupStep() {
|
||||||
|
if (_isApplyingDeviceSetup) return;
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Top bar with skip button
|
// Top bar with skip button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -96,10 +352,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
child: PageView(
|
child: PageView(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
_buildWelcomePage(context, l10n, colorScheme),
|
_buildWelcomePage(context, l10n, colorScheme),
|
||||||
|
_buildConnectDevicePage(context, l10n, colorScheme),
|
||||||
_buildConnectingPage(context, l10n, colorScheme),
|
_buildConnectingPage(context, l10n, colorScheme),
|
||||||
_buildSimpleModePage(context, l10n, colorScheme),
|
|
||||||
_buildChannelPage(context, l10n, colorScheme),
|
_buildChannelPage(context, l10n, colorScheme),
|
||||||
_buildContactsPage(context, l10n, colorScheme),
|
_buildContactsPage(context, l10n, colorScheme),
|
||||||
_buildMapPage(context, l10n, colorScheme),
|
_buildMapPage(context, l10n, colorScheme),
|
||||||
@@ -109,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
|
|
||||||
// Page indicators
|
// Page indicators
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
@@ -131,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
|
|
||||||
// Next/Get Started button
|
// Next/Get Started button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _nextPage,
|
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -143,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_currentPage < _totalPages - 1
|
_currentPage == 1
|
||||||
|
? _deviceSetupButtonLabel(context)
|
||||||
|
: _currentPage < _totalPages - 1
|
||||||
? l10n.wizardNext
|
? l10n.wizardNext
|
||||||
: l10n.wizardGetStarted,
|
: l10n.wizardGetStarted,
|
||||||
style: const TextStyle(fontSize: 16),
|
style: const TextStyle(fontSize: 16),
|
||||||
@@ -166,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
icon: Icons.waving_hand,
|
icon: Icons.waving_hand,
|
||||||
iconColor: Colors.orange,
|
iconColor: Colors.orange,
|
||||||
title: l10n.wizardWelcomeTitle,
|
title: l10n.wizardWelcomeTitle,
|
||||||
description: l10n.wizardWelcomeDescription,
|
description: l10n.wizardOverviewDescription,
|
||||||
|
features: [
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.chat_bubble_outline,
|
||||||
|
text: l10n.wizardOverviewFeature1,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.emergency_share,
|
||||||
|
text: l10n.wizardOverviewFeature2,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.settings_input_antenna,
|
||||||
|
text: l10n.wizardOverviewFeature3,
|
||||||
|
),
|
||||||
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,75 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.bluetooth_searching,
|
icon: Icons.forum_rounded,
|
||||||
iconColor: Colors.blue,
|
iconColor: Colors.blue,
|
||||||
title: l10n.wizardConnectingTitle,
|
title: l10n.wizardMessagingTitle,
|
||||||
description: l10n.wizardConnectingDescription,
|
description: l10n.wizardMessagingDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
|
||||||
icon: Icons.radio,
|
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
|
||||||
text: l10n.wizardConnectingFeature1,
|
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.link,
|
|
||||||
text: l10n.wizardConnectingFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.wifi_off,
|
|
||||||
text: l10n.wizardConnectingFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSimpleModePage(
|
Widget _buildConnectDevicePage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
icon: Icons.toggle_on,
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
iconColor: Colors.green,
|
final isConnected = deviceInfo.isConnected;
|
||||||
title: l10n.wizardSimpleModeTitle,
|
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
|
||||||
description: l10n.wizardSimpleModeDescription,
|
|
||||||
features: [
|
return LayoutBuilder(
|
||||||
_FeatureItem(
|
builder: (context, constraints) => Center(
|
||||||
icon: Icons.check_circle_outline,
|
child: ConstrainedBox(
|
||||||
text: l10n.wizardSimpleModeFeature1,
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 600,
|
||||||
|
maxHeight: constraints.maxHeight,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildPageHeader(
|
||||||
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
icon: Icons.bluetooth_connected,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
title: l10n.wizardConnectDeviceTitle,
|
||||||
|
description: l10n.wizardConnectDeviceDescription,
|
||||||
|
badge: l10n.wizardSetupBadge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isConnected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.bluetooth_searching,
|
||||||
|
color: isConnected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isConnected
|
||||||
|
? l10n.wizardConnectedToDevice(
|
||||||
|
connectedName ?? l10n.device,
|
||||||
|
)
|
||||||
|
: l10n.wizardNoDeviceConnected,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _openConnectionDialog,
|
||||||
|
child: Text(
|
||||||
|
isConnected ? l10n.change : l10n.connect,
|
||||||
),
|
),
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.settings,
|
|
||||||
text: l10n.wizardSimpleModeFeature2,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _skipDeviceSetupStep,
|
||||||
|
child: Text(l10n.wizardSkipForNow),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildContentCard(
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _deviceNameController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.wizardDeviceNameLabel,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: l10n.wizardDeviceNameHelp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<_RadioPreset>(
|
||||||
|
initialValue: _selectedPreset,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.wizardConfigRegionLabel,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: l10n.wizardConfigRegionHelp,
|
||||||
|
),
|
||||||
|
items: _radioPresets
|
||||||
|
.map(
|
||||||
|
(preset) => DropdownMenuItem<_RadioPreset>(
|
||||||
|
value: preset,
|
||||||
|
child: Text(
|
||||||
|
preset.label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
onChanged: (preset) {
|
||||||
|
if (preset == null) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedPreset = preset;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_selectedPreset.label,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_selectedPreset.summary,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
...[
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.rule,
|
||||||
|
text: l10n.wizardPresetNote1,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.settings_input_antenna,
|
||||||
|
text: l10n.wizardPresetNote2,
|
||||||
|
),
|
||||||
|
_FeatureItem(
|
||||||
|
icon: Icons.public,
|
||||||
|
text: l10n.wizardPresetNote3,
|
||||||
|
),
|
||||||
|
].map(
|
||||||
|
(feature) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
child: _buildFeatureRow(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
feature,
|
||||||
|
iconSize: 20,
|
||||||
|
gap: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _deviceSetupButtonLabel(BuildContext context) {
|
||||||
|
final isConnected = context
|
||||||
|
.watch<ConnectionProvider>()
|
||||||
|
.deviceInfo
|
||||||
|
.isConnected;
|
||||||
|
if (_isApplyingDeviceSetup) {
|
||||||
|
return AppLocalizations.of(context)!.wizardSaving;
|
||||||
|
}
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return isConnected
|
||||||
|
? l10n.wizardSaveAndContinue
|
||||||
|
: l10n.wizardConnectDeviceTitle;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildChannelPage(
|
Widget _buildChannelPage(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AppLocalizations l10n,
|
AppLocalizations l10n,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.campaign,
|
icon: Icons.groups_rounded,
|
||||||
iconColor: Colors.purple,
|
iconColor: Colors.purple,
|
||||||
title: l10n.wizardChannelTitle,
|
title: l10n.wizardNetworkTitle,
|
||||||
description: l10n.wizardChannelDescription,
|
description: l10n.wizardNetworkDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.public,
|
icon: Icons.person_add_alt_1,
|
||||||
text: l10n.wizardChannelFeature1,
|
text: l10n.wizardNetworkFeature1,
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.groups,
|
|
||||||
text: l10n.wizardChannelFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.send,
|
|
||||||
text: l10n.wizardChannelFeature3,
|
|
||||||
),
|
),
|
||||||
|
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
|
||||||
|
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -257,23 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.people,
|
icon: Icons.map_rounded,
|
||||||
iconColor: Colors.teal,
|
iconColor: Colors.teal,
|
||||||
title: l10n.wizardContactsTitle,
|
title: l10n.wizardMapOpsTitle,
|
||||||
description: l10n.wizardContactsDescription,
|
description: l10n.wizardMapOpsDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
|
||||||
icon: Icons.person_add,
|
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
|
||||||
text: l10n.wizardContactsFeature1,
|
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.chat,
|
|
||||||
text: l10n.wizardContactsFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.battery_std,
|
|
||||||
text: l10n.wizardContactsFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -285,26 +699,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
return _buildPage(
|
return _buildPage(
|
||||||
icon: Icons.map,
|
icon: Icons.tune_rounded,
|
||||||
iconColor: Colors.red,
|
iconColor: Colors.red,
|
||||||
title: l10n.wizardMapTitle,
|
title: l10n.wizardToolsTitle,
|
||||||
description: l10n.wizardMapDescription,
|
description: l10n.wizardToolsDescription,
|
||||||
features: [
|
features: [
|
||||||
|
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
|
||||||
|
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.location_on,
|
icon: Icons.analytics_outlined,
|
||||||
text: l10n.wizardMapFeature1,
|
text: l10n.wizardToolsFeature3,
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.person_pin_circle,
|
|
||||||
text: l10n.wizardMapFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.offline_pin,
|
|
||||||
text: l10n.wizardMapFeature3,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.draw,
|
|
||||||
text: l10n.wizardMapFeature4,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -319,74 +723,147 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
List<_FeatureItem>? features,
|
List<_FeatureItem>? features,
|
||||||
required ColorScheme colorScheme,
|
required ColorScheme colorScheme,
|
||||||
}) {
|
}) {
|
||||||
return SingleChildScrollView(
|
final l10n = AppLocalizations.of(context)!;
|
||||||
padding: const EdgeInsets.all(24.0),
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 560,
|
||||||
|
maxHeight: constraints.maxHeight,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
_buildPageHeader(
|
||||||
// Icon
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(24.0),
|
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(
|
decoration: BoxDecoration(
|
||||||
color: iconColor.withValues(alpha: 0.1),
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, size: 56, color: iconColor),
|
||||||
icon,
|
|
||||||
size: 80,
|
|
||||||
color: iconColor,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 18),
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Title
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 10),
|
||||||
// Description
|
|
||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||||
height: 1.5,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
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),
|
Widget _buildContentCard({
|
||||||
child: Row(
|
required ColorScheme colorScheme,
|
||||||
children: [
|
required Widget child,
|
||||||
Icon(
|
}) {
|
||||||
feature.icon,
|
return Container(
|
||||||
color: colorScheme.primary,
|
padding: const EdgeInsets.all(16),
|
||||||
size: 24,
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
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(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
feature.text,
|
feature.text,
|
||||||
style:
|
style: Theme.of(
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
context,
|
||||||
color: colorScheme.onSurface,
|
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,3 +874,23 @@ class _FeatureItem {
|
|||||||
|
|
||||||
_FeatureItem({required this.icon, required this.text});
|
_FeatureItem({required this.icon, required this.text});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RadioPreset {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String summary;
|
||||||
|
final int frequencyKhz;
|
||||||
|
final int bandwidth;
|
||||||
|
final int spreadingFactor;
|
||||||
|
final int codingRate;
|
||||||
|
|
||||||
|
const _RadioPreset({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.summary,
|
||||||
|
required this.frequencyKhz,
|
||||||
|
required this.bandwidth,
|
||||||
|
required this.spreadingFactor,
|
||||||
|
required this.codingRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -58,6 +59,16 @@ class BackgroundLocationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_prefKeyEnabled, true);
|
await prefs.setBool(_prefKeyEnabled, true);
|
||||||
@@ -68,8 +79,7 @@ class BackgroundLocationService {
|
|||||||
try {
|
try {
|
||||||
_positionSubscription =
|
_positionSubscription =
|
||||||
Geolocator.getPositionStream(
|
Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceThreshold.toInt(),
|
distanceFilter: distanceThreshold.toInt(),
|
||||||
),
|
),
|
||||||
).listen((Position position) async {
|
).listen((Position position) async {
|
||||||
@@ -171,4 +181,22 @@ class BackgroundLocationService {
|
|||||||
await startTracking(distanceThreshold: distance);
|
await startTracking(distanceThreshold: distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
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,12 +2,14 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
@@ -90,6 +92,40 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = jsonEncode(
|
||||||
|
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||||
|
);
|
||||||
|
await prefs.setString(_contactGroupsKey, jsonString);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedContactGroup>> loadContactGroups() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_contactGroupsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -203,4 +239,31 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
246
lib/services/live_traffic_summary.dart
Normal file
246
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
enum LiveTrafficBusyness { quiet, active, busy }
|
||||||
|
|
||||||
|
class LiveTrafficEntry {
|
||||||
|
final BlePacketLog log;
|
||||||
|
final DecodedLogRxRoute? route;
|
||||||
|
|
||||||
|
const LiveTrafficEntry({required this.log, required this.route});
|
||||||
|
|
||||||
|
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||||
|
|
||||||
|
int? get hopCount => route?.hopCount;
|
||||||
|
|
||||||
|
String get payloadLabel {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||||
|
}
|
||||||
|
return payloadTypeLabel(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get payloadMeaning {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) return null;
|
||||||
|
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get routePreview {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||||
|
return 'Direct packet';
|
||||||
|
}
|
||||||
|
return decodedRoute.hopHashes
|
||||||
|
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||||
|
.join(' -> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response';
|
||||||
|
case 0x02:
|
||||||
|
return 'Text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Ack';
|
||||||
|
case 0x04:
|
||||||
|
return 'Advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Group text';
|
||||||
|
case 0x06:
|
||||||
|
return 'Group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path';
|
||||||
|
case 0x0A:
|
||||||
|
return 'Multipart packet';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control packet';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeMeaning(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request (destination/source hashes + MAC)';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response to Request or Anonymous request';
|
||||||
|
case 0x02:
|
||||||
|
return 'Plain text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Simple acknowledgement';
|
||||||
|
case 0x04:
|
||||||
|
return 'Node advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Unverified group text message';
|
||||||
|
case 0x06:
|
||||||
|
return 'Unverified group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Generic anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path payload';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path collecting hop SNR';
|
||||||
|
case 0x0A:
|
||||||
|
return 'One packet from a multipart set';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control or discovery packet';
|
||||||
|
default:
|
||||||
|
return 'protocol payload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSnapshot {
|
||||||
|
final DateTime windowStart;
|
||||||
|
final Duration windowDuration;
|
||||||
|
final int packetsPerMinute;
|
||||||
|
final int rxCount;
|
||||||
|
final int txCount;
|
||||||
|
final int totalCount;
|
||||||
|
final double? avgSnrDb;
|
||||||
|
final double? latestSnrDb;
|
||||||
|
final double? avgRssiDbm;
|
||||||
|
final int? latestRssiDbm;
|
||||||
|
final int multiHopCount;
|
||||||
|
final double? avgHopCount;
|
||||||
|
final List<LiveTrafficEntry> visibleEntries;
|
||||||
|
final LiveTrafficBusyness busyness;
|
||||||
|
|
||||||
|
const LiveTrafficSnapshot({
|
||||||
|
required this.windowStart,
|
||||||
|
required this.windowDuration,
|
||||||
|
required this.packetsPerMinute,
|
||||||
|
required this.rxCount,
|
||||||
|
required this.txCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.avgSnrDb,
|
||||||
|
required this.latestSnrDb,
|
||||||
|
required this.avgRssiDbm,
|
||||||
|
required this.latestRssiDbm,
|
||||||
|
required this.multiHopCount,
|
||||||
|
required this.avgHopCount,
|
||||||
|
required this.visibleEntries,
|
||||||
|
required this.busyness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSummary {
|
||||||
|
static const Duration rollingWindow = Duration(seconds: 60);
|
||||||
|
static const int maxVisibleEntries = 120;
|
||||||
|
static const int logRxDataResponseCode = 0x88;
|
||||||
|
|
||||||
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static LiveTrafficSnapshot fromLogs(
|
||||||
|
Iterable<BlePacketLog> logs, {
|
||||||
|
required DateTime now,
|
||||||
|
DateTime? clearedAt,
|
||||||
|
int? preferredHashSize,
|
||||||
|
Duration window = rollingWindow,
|
||||||
|
String? packetTypeFilter,
|
||||||
|
}) {
|
||||||
|
final windowStart = now.subtract(window);
|
||||||
|
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||||
|
? clearedAt
|
||||||
|
: windowStart;
|
||||||
|
|
||||||
|
final recentLogs = logs
|
||||||
|
.where(
|
||||||
|
(log) =>
|
||||||
|
log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode &&
|
||||||
|
!log.timestamp.isBefore(effectiveStart),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
final entries = <LiveTrafficEntry>[];
|
||||||
|
for (final log in recentLogs) {
|
||||||
|
final route = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: preferredHashSize,
|
||||||
|
);
|
||||||
|
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredEntries = packetTypeFilter == null
|
||||||
|
? entries
|
||||||
|
: entries
|
||||||
|
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var rxCount = 0;
|
||||||
|
var snrCount = 0;
|
||||||
|
var snrSum = 0.0;
|
||||||
|
var rssiCount = 0;
|
||||||
|
var rssiSum = 0.0;
|
||||||
|
double? latestSnrDb;
|
||||||
|
int? latestRssiDbm;
|
||||||
|
var multiHopCount = 0;
|
||||||
|
var hopCountTotal = 0;
|
||||||
|
var hopCountSamples = 0;
|
||||||
|
|
||||||
|
for (final entry in filteredEntries) {
|
||||||
|
rxCount += 1;
|
||||||
|
|
||||||
|
final rxInfo = entry.log.logRxDataInfo;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
snrCount += 1;
|
||||||
|
snrSum += rxInfo!.snrDb!;
|
||||||
|
latestSnrDb = rxInfo.snrDb!;
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
rssiCount += 1;
|
||||||
|
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||||
|
latestRssiDbm = rxInfo.rssiDbm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final route = entry.route;
|
||||||
|
if (route != null && route.hopCount > 0) {
|
||||||
|
hopCountSamples += 1;
|
||||||
|
hopCountTotal += route.hopCount;
|
||||||
|
if (route.hopCount > 1) {
|
||||||
|
multiHopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
|
const txCount = 0;
|
||||||
|
final totalCount = rxCount;
|
||||||
|
final packetsPerMinute = totalCount;
|
||||||
|
|
||||||
|
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,12 @@ class LocalePreferences {
|
|||||||
Locale('fr'), // French
|
Locale('fr'), // French
|
||||||
Locale('it'), // Italian
|
Locale('it'), // Italian
|
||||||
Locale('el'), // Greek
|
Locale('el'), // Greek
|
||||||
|
Locale('pt'), // Portuguese
|
||||||
|
Locale('tr'), // Turkish
|
||||||
|
Locale('pl'), // Polish
|
||||||
|
Locale('uk'), // Ukrainian
|
||||||
|
Locale('ru'), // Russian
|
||||||
|
Locale('zh'), // Chinese
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Get the saved locale or return null to use system locale
|
/// Get the saved locale or return null to use system locale
|
||||||
@@ -64,6 +70,16 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
|
case 'pt':
|
||||||
|
return 'Portuguese';
|
||||||
|
case 'tr':
|
||||||
|
return 'Turkish';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polish';
|
||||||
|
case 'uk':
|
||||||
|
return 'Ukrainian';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
@@ -90,6 +106,16 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
|
case 'pt':
|
||||||
|
return 'Português';
|
||||||
|
case 'tr':
|
||||||
|
return 'Türkçe';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polski';
|
||||||
|
case 'uk':
|
||||||
|
return 'Українська';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
case 'zh':
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -41,6 +42,12 @@ class LocationTrackingService {
|
|||||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||||
static const String _prefKeyLastLat = 'background_last_lat';
|
static const String _prefKeyLastLat = 'background_last_lat';
|
||||||
static const String _prefKeyLastLon = 'background_last_lon';
|
static const String _prefKeyLastLon = 'background_last_lon';
|
||||||
|
static const String _prefKeyFastLocationEnabled =
|
||||||
|
'fast_location_updates_enabled';
|
||||||
|
static const String _prefKeyFastMovementThreshold =
|
||||||
|
'fast_location_movement_threshold_meters';
|
||||||
|
static const String _prefKeyFastActiveCadence =
|
||||||
|
'fast_location_active_cadence_seconds';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Configuration Properties
|
// Configuration Properties
|
||||||
@@ -58,6 +65,15 @@ class LocationTrackingService {
|
|||||||
/// GPS update distance filter for position stream
|
/// GPS update distance filter for position stream
|
||||||
double gpsUpdateDistance = 10.0;
|
double gpsUpdateDistance = 10.0;
|
||||||
|
|
||||||
|
/// Whether private fast GPS updates are enabled
|
||||||
|
bool fastLocationUpdatesEnabled = false;
|
||||||
|
|
||||||
|
/// Distance threshold for fast GPS updates
|
||||||
|
double fastLocationMovementThresholdMeters = 10.0;
|
||||||
|
|
||||||
|
/// Cadence for active-use fast GPS updates
|
||||||
|
int fastLocationActiveCadenceSeconds = 10;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Properties
|
// State Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -84,6 +100,11 @@ class LocationTrackingService {
|
|||||||
/// Position stream subscription
|
/// Position stream subscription
|
||||||
StreamSubscription<Position>? _positionSubscription;
|
StreamSubscription<Position>? _positionSubscription;
|
||||||
|
|
||||||
|
Timer? _fastLocationTimer;
|
||||||
|
bool _isFastLocationActiveUse = false;
|
||||||
|
DateTime? _lastFastLocationSentAt;
|
||||||
|
Position? _lastFastLocationSentPosition;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Callback Properties
|
// Callback Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -100,6 +121,9 @@ class LocationTrackingService {
|
|||||||
/// Called when tracking state changes
|
/// Called when tracking state changes
|
||||||
void Function(bool isTracking)? onTrackingStateChanged;
|
void Function(bool isTracking)? onTrackingStateChanged;
|
||||||
|
|
||||||
|
/// Called when a fast private GPS update should be sent
|
||||||
|
void Function(Position position, String reason)? onFastLocationUpdate;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Initialization
|
// Initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -125,6 +149,10 @@ class LocationTrackingService {
|
|||||||
/// Check if location permissions are granted
|
/// Check if location permissions are granted
|
||||||
Future<bool> checkPermissions() async {
|
Future<bool> checkPermissions() async {
|
||||||
final permission = await Geolocator.checkPermission();
|
final permission = await Geolocator.checkPermission();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return permission == LocationPermission.always;
|
||||||
|
}
|
||||||
|
|
||||||
return permission == LocationPermission.always ||
|
return permission == LocationPermission.always ||
|
||||||
permission == LocationPermission.whileInUse;
|
permission == LocationPermission.whileInUse;
|
||||||
}
|
}
|
||||||
@@ -158,6 +186,16 @@ class LocationTrackingService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
onError?.call(
|
||||||
|
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -178,35 +216,45 @@ class LocationTrackingService {
|
|||||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
debugPrint(
|
||||||
|
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
|
||||||
|
);
|
||||||
// Exponential backoff: wait 2^attempt seconds before retry
|
// Exponential backoff: wait 2^attempt seconds before retry
|
||||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||||
}
|
}
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
timeLimit: timeLimit,
|
timeLimit: timeLimit,
|
||||||
|
distanceFilter: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
currentPosition = position;
|
currentPosition = position;
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
debugPrint(
|
||||||
|
'✅ [LocationTracking] Position acquired after $attempt retries',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return position;
|
return position;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final isLastAttempt = attempt == retryCount;
|
final isLastAttempt = attempt == retryCount;
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
debugPrint(
|
||||||
|
'❌ [LocationTracking] Failed to get position after $retryCount retries: $e',
|
||||||
|
);
|
||||||
// Only call error callback on final failure, and make it user-friendly
|
// Only call error callback on final failure, and make it user-friendly
|
||||||
if (e.toString().contains('TimeoutException')) {
|
if (e.toString().contains('TimeoutException')) {
|
||||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
onError?.call(
|
||||||
|
'GPS signal weak. Position stream will continue trying...',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
onError?.call('Failed to get GPS position. Check device settings.');
|
onError?.call('Failed to get GPS position. Check device settings.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
@@ -222,13 +270,35 @@ class LocationTrackingService {
|
|||||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||||
return Geolocator.getPositionStream(
|
return Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceFilter.toInt(),
|
distanceFilter: distanceFilter.toInt(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({
|
||||||
|
int distanceFilter = 10,
|
||||||
|
Duration? timeLimit,
|
||||||
|
}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tracking Control
|
// Tracking Control
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -244,16 +314,16 @@ class LocationTrackingService {
|
|||||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
debugPrint(
|
debugPrint('⚠️ [LocationTracking] Service not initialized');
|
||||||
'⚠️ [LocationTracking] Service not initialized',
|
|
||||||
);
|
|
||||||
onError?.call('Location tracking service not initialized');
|
onError?.call('Location tracking service not initialized');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
debugPrint(
|
||||||
|
'ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permissions
|
// Check permissions
|
||||||
@@ -271,15 +341,18 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
// Try to get initial position in background (non-blocking)
|
// Try to get initial position in background (non-blocking)
|
||||||
// This will populate currentPosition but won't block tracking startup
|
// This will populate currentPosition but won't block tracking startup
|
||||||
getCurrentPosition(
|
getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
|
||||||
timeLimit: const Duration(seconds: 10),
|
.then((position) {
|
||||||
retryCount: 1,
|
|
||||||
).then((position) {
|
|
||||||
if (position != null) {
|
if (position != null) {
|
||||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
debugPrint(
|
||||||
|
'✅ [LocationTracking] Initial position acquired in background',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}).catchError((error) {
|
})
|
||||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
.catchError((error) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Background initial position failed: $error',
|
||||||
|
);
|
||||||
// Not critical - position stream will eventually provide position
|
// Not critical - position stream will eventually provide position
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -296,6 +369,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = true;
|
isTracking = true;
|
||||||
onTrackingStateChanged?.call(true);
|
onTrackingStateChanged?.call(true);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||||
@@ -318,6 +392,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = false;
|
isTracking = false;
|
||||||
onTrackingStateChanged?.call(false);
|
onTrackingStateChanged?.call(false);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
// Reset first position flag so next connection starts fresh
|
// Reset first position flag so next connection starts fresh
|
||||||
_firstPositionSet = false;
|
_firstPositionSet = false;
|
||||||
@@ -361,6 +436,8 @@ class LocationTrackingService {
|
|||||||
// Notify listeners
|
// Notify listeners
|
||||||
onPositionUpdate?.call(position);
|
onPositionUpdate?.call(position);
|
||||||
|
|
||||||
|
_evaluateFastLocationMovement(position);
|
||||||
|
|
||||||
// SPECIAL CASE: First stable position after connection
|
// SPECIAL CASE: First stable position after connection
|
||||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||||
if (!_firstPositionSet) {
|
if (!_firstPositionSet) {
|
||||||
@@ -378,12 +455,16 @@ class LocationTrackingService {
|
|||||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||||
void _setInitialPosition(Position position) async {
|
void _setInitialPosition(Position position) async {
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
debugPrint(
|
||||||
|
'📍 [LocationTracking] Setting initial position (no broadcast)',
|
||||||
|
);
|
||||||
|
|
||||||
// Update device's advertised location WITHOUT sending advertisement
|
// Update device's advertised location WITHOUT sending advertisement
|
||||||
await _bleService!.setAdvertLatLon(
|
await _bleService!.setAdvertLatLon(
|
||||||
@@ -414,7 +495,94 @@ class LocationTrackingService {
|
|||||||
void _checkAndBroadcast(Position position) {
|
void _checkAndBroadcast(Position position) {
|
||||||
// Automatic broadcasting disabled
|
// Automatic broadcasting disabled
|
||||||
// Use the manual advert button instead
|
// Use the manual advert button instead
|
||||||
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
|
debugPrint(
|
||||||
|
' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFastLocationActiveUse(bool isActive) {
|
||||||
|
if (_isFastLocationActiveUse == isActive) return;
|
||||||
|
_isFastLocationActiveUse = isActive;
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
||||||
|
fastLocationUpdatesEnabled = enabled;
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationMovementThreshold(double meters) async {
|
||||||
|
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
|
||||||
|
await saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
|
||||||
|
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evaluateFastLocationMovement(Position position) {
|
||||||
|
if (!fastLocationUpdatesEnabled) return;
|
||||||
|
final previous = _lastFastLocationSentPosition;
|
||||||
|
if (previous == null) {
|
||||||
|
_emitFastLocationUpdate(position, reason: 'initial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distance = Geolocator.distanceBetween(
|
||||||
|
previous.latitude,
|
||||||
|
previous.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
if (distance >= fastLocationMovementThresholdMeters) {
|
||||||
|
_emitFastLocationUpdate(position, reason: 'movement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshFastLocationTimer() {
|
||||||
|
_fastLocationTimer?.cancel();
|
||||||
|
_fastLocationTimer = null;
|
||||||
|
if (!isTracking ||
|
||||||
|
!fastLocationUpdatesEnabled ||
|
||||||
|
!_isFastLocationActiveUse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_fastLocationTimer = Timer.periodic(
|
||||||
|
Duration(seconds: fastLocationActiveCadenceSeconds),
|
||||||
|
(_) {
|
||||||
|
final position = currentPosition;
|
||||||
|
if (position == null) return;
|
||||||
|
_emitFastLocationUpdate(position, reason: 'active_use');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||||
|
if (!fastLocationUpdatesEnabled) return;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final previous = _lastFastLocationSentPosition;
|
||||||
|
final previousTime = _lastFastLocationSentAt;
|
||||||
|
if (previous != null && previousTime != null) {
|
||||||
|
final distance = Geolocator.distanceBetween(
|
||||||
|
previous.latitude,
|
||||||
|
previous.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
final elapsedMs = now.difference(previousTime).inMilliseconds;
|
||||||
|
if (distance < 1.0 && elapsedMs < 3000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastFastLocationSentPosition = position;
|
||||||
|
_lastFastLocationSentAt = now;
|
||||||
|
onFastLocationUpdate?.call(position, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -457,7 +625,9 @@ class LocationTrackingService {
|
|||||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||||
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
|
debugPrint(
|
||||||
|
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
|
||||||
|
);
|
||||||
onBroadcastSent?.call(position);
|
onBroadcastSent?.call(position);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -480,12 +650,24 @@ class LocationTrackingService {
|
|||||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||||
|
fastLocationUpdatesEnabled =
|
||||||
|
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
|
||||||
|
fastLocationMovementThresholdMeters =
|
||||||
|
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
|
||||||
|
.clamp(1.0, 1000.0);
|
||||||
|
fastLocationActiveCadenceSeconds =
|
||||||
|
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
||||||
|
debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled');
|
||||||
|
debugPrint(
|
||||||
|
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
||||||
|
);
|
||||||
|
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to SharedPreferences
|
/// Save settings to SharedPreferences
|
||||||
@@ -497,6 +679,18 @@ class LocationTrackingService {
|
|||||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||||
|
await prefs.setBool(
|
||||||
|
_prefKeyFastLocationEnabled,
|
||||||
|
fastLocationUpdatesEnabled,
|
||||||
|
);
|
||||||
|
await prefs.setDouble(
|
||||||
|
_prefKeyFastMovementThreshold,
|
||||||
|
fastLocationMovementThresholdMeters,
|
||||||
|
);
|
||||||
|
await prefs.setInt(
|
||||||
|
_prefKeyFastActiveCadence,
|
||||||
|
fastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings saved');
|
debugPrint('✅ [LocationTracking] Settings saved');
|
||||||
}
|
}
|
||||||
@@ -509,6 +703,7 @@ class LocationTrackingService {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||||
_positionSubscription?.cancel();
|
_positionSubscription?.cancel();
|
||||||
|
_fastLocationTimer?.cancel();
|
||||||
_positionSubscription = null;
|
_positionSubscription = null;
|
||||||
_bleService = null;
|
_bleService = null;
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:mbtiles/mbtiles.dart';
|
|
||||||
|
|
||||||
/// Metadata information extracted from an MBTiles file
|
|
||||||
class MbtilesMetadata {
|
|
||||||
final String name;
|
|
||||||
final String? description;
|
|
||||||
final String? version;
|
|
||||||
final String? attribution;
|
|
||||||
final String? bounds; // "minLon,minLat,maxLon,maxLat"
|
|
||||||
final String? center; // "lon,lat,zoom"
|
|
||||||
final int? minZoom;
|
|
||||||
final int? maxZoom;
|
|
||||||
final String? format; // "pbf", "png", "jpg", etc.
|
|
||||||
final String? type; // "overlay", "baselayer"
|
|
||||||
final String? json; // Additional metadata JSON
|
|
||||||
final File file;
|
|
||||||
final int fileSize;
|
|
||||||
|
|
||||||
const MbtilesMetadata({
|
|
||||||
required this.name,
|
|
||||||
this.description,
|
|
||||||
this.version,
|
|
||||||
this.attribution,
|
|
||||||
this.bounds,
|
|
||||||
this.center,
|
|
||||||
this.minZoom,
|
|
||||||
this.maxZoom,
|
|
||||||
this.format,
|
|
||||||
this.type,
|
|
||||||
this.json,
|
|
||||||
required this.file,
|
|
||||||
required this.fileSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Check if this is a vector tile MBTiles file
|
|
||||||
bool get isVector => format == 'pbf' || format == 'mvt';
|
|
||||||
|
|
||||||
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
|
|
||||||
List<double>? get boundsCoordinates {
|
|
||||||
if (bounds == null) return null;
|
|
||||||
try {
|
|
||||||
final parts = bounds!.split(',');
|
|
||||||
if (parts.length != 4) return null;
|
|
||||||
return parts.map((s) => double.parse(s.trim())).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error parsing bounds: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse center string into [lon, lat, zoom]
|
|
||||||
List<double>? get centerCoordinates {
|
|
||||||
if (center == null) return null;
|
|
||||||
try {
|
|
||||||
final parts = center!.split(',');
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
return parts.map((s) => double.parse(s.trim())).toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error parsing center: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get file size in human-readable format
|
|
||||||
String get fileSizeFormatted {
|
|
||||||
if (fileSize < 1024) {
|
|
||||||
return '$fileSize B';
|
|
||||||
} else if (fileSize < 1024 * 1024) {
|
|
||||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
|
||||||
} else if (fileSize < 1024 * 1024 * 1024) {
|
|
||||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
||||||
} else {
|
|
||||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service for managing MBTiles files for offline vector maps
|
|
||||||
class MbtilesService {
|
|
||||||
static const String _mbtilesDirectory = 'offline_maps';
|
|
||||||
|
|
||||||
/// Get the directory where MBTiles files are stored
|
|
||||||
Future<Directory> getMbtilesDirectory() async {
|
|
||||||
final appDocDir = await getApplicationDocumentsDirectory();
|
|
||||||
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
|
|
||||||
|
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if (!await mbtilesDir.exists()) {
|
|
||||||
await mbtilesDir.create(recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return mbtilesDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all MBTiles files in the offline maps directory
|
|
||||||
Future<List<File>> listMbtilesFiles() async {
|
|
||||||
final dir = await getMbtilesDirectory();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final files = await dir
|
|
||||||
.list()
|
|
||||||
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
|
|
||||||
.map((entity) => entity as File)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return files;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error listing MBTiles files: $e');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get metadata from an MBTiles file
|
|
||||||
Future<MbtilesMetadata?> getMetadata(File file) async {
|
|
||||||
try {
|
|
||||||
// Check if file exists
|
|
||||||
if (!await file.exists()) {
|
|
||||||
debugPrint('MBTiles file does not exist: ${file.path}');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get file size
|
|
||||||
final fileSize = await file.length();
|
|
||||||
|
|
||||||
// Open MBTiles file
|
|
||||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
|
||||||
|
|
||||||
// Get metadata from MBTiles
|
|
||||||
final metadata = mbtiles.getMetadata();
|
|
||||||
|
|
||||||
// Convert bounds object to string if available
|
|
||||||
String? boundsStr;
|
|
||||||
if (metadata.bounds != null) {
|
|
||||||
boundsStr = metadata.bounds.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return MbtilesMetadata(
|
|
||||||
name: metadata.name,
|
|
||||||
description: metadata.description,
|
|
||||||
version: metadata.version?.toString(),
|
|
||||||
attribution: null, // Not available in new API
|
|
||||||
bounds: boundsStr,
|
|
||||||
center: null, // Not available in new API
|
|
||||||
minZoom: metadata.minZoom?.toInt(),
|
|
||||||
maxZoom: metadata.maxZoom?.toInt(),
|
|
||||||
format: metadata.format,
|
|
||||||
type: metadata.type?.name,
|
|
||||||
json: null, // Not available in new API
|
|
||||||
file: file,
|
|
||||||
fileSize: fileSize,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get metadata for all MBTiles files
|
|
||||||
Future<List<MbtilesMetadata>> getAllMetadata() async {
|
|
||||||
final files = await listMbtilesFiles();
|
|
||||||
final metadataList = <MbtilesMetadata>[];
|
|
||||||
|
|
||||||
for (final file in files) {
|
|
||||||
final metadata = await getMetadata(file);
|
|
||||||
if (metadata != null) {
|
|
||||||
metadataList.add(metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataList;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import an MBTiles file from an external location
|
|
||||||
Future<File?> importMbtilesFile(String sourcePath) async {
|
|
||||||
try {
|
|
||||||
final sourceFile = File(sourcePath);
|
|
||||||
|
|
||||||
// Verify source file exists
|
|
||||||
if (!await sourceFile.exists()) {
|
|
||||||
debugPrint('Source file does not exist: $sourcePath');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get destination directory
|
|
||||||
final destDir = await getMbtilesDirectory();
|
|
||||||
final fileName = _getFileName(sourceFile);
|
|
||||||
final destPath = '${destDir.path}/$fileName';
|
|
||||||
|
|
||||||
// Copy file to destination
|
|
||||||
final destFile = await sourceFile.copy(destPath);
|
|
||||||
debugPrint('Imported MBTiles file to: $destPath');
|
|
||||||
|
|
||||||
return destFile;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error importing MBTiles file: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an MBTiles file
|
|
||||||
Future<bool> deleteMbtilesFile(File file) async {
|
|
||||||
try {
|
|
||||||
if (await file.exists()) {
|
|
||||||
await file.delete();
|
|
||||||
debugPrint('Deleted MBTiles file: ${file.path}');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error deleting MBTiles file: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if data in MBTiles is gzip compressed
|
|
||||||
Future<bool> isGzipCompressed(File file) async {
|
|
||||||
try {
|
|
||||||
// Open MBTiles and check a sample tile
|
|
||||||
final mbtiles = MbTiles(mbtilesPath: file.path);
|
|
||||||
|
|
||||||
// Try to get metadata to check for compression hints
|
|
||||||
final metadata = mbtiles.getMetadata();
|
|
||||||
final format = metadata.format;
|
|
||||||
|
|
||||||
// For Geofabrik files, format is 'pbf' and data is gzipped
|
|
||||||
// We can infer this from common patterns, but ideally we'd check actual tile data
|
|
||||||
if (format == 'pbf') {
|
|
||||||
// Geofabrik MBTiles are typically gzipped
|
|
||||||
// Could also check tile data headers, but this is a reasonable heuristic
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error checking gzip compression: $e');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the vector tile schema from metadata
|
|
||||||
String? getVectorSchema(MbtilesMetadata metadata) {
|
|
||||||
// Try to infer schema from metadata
|
|
||||||
final json = metadata.json;
|
|
||||||
if (json != null) {
|
|
||||||
if (json.contains('shortbread')) {
|
|
||||||
return 'shortbread';
|
|
||||||
} else if (json.contains('openmaptiles')) {
|
|
||||||
return 'openmaptiles';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check description
|
|
||||||
final description = metadata.description?.toLowerCase();
|
|
||||||
if (description != null) {
|
|
||||||
if (description.contains('shortbread')) {
|
|
||||||
return 'shortbread';
|
|
||||||
} else if (description.contains('openmaptiles')) {
|
|
||||||
return 'openmaptiles';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to unknown
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Get file name from path
|
|
||||||
String _getFileName(File file) {
|
|
||||||
return file.path.split(Platform.pathSeparator).last;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MeshMapNode {
|
class MeshMapNode {
|
||||||
final int type;
|
final int type;
|
||||||
@@ -33,25 +35,36 @@ class MeshMapNode {
|
|||||||
class MeshMapNodesService {
|
class MeshMapNodesService {
|
||||||
static const String _nodesEndpoint =
|
static const String _nodesEndpoint =
|
||||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
static const int repeaterType = 1;
|
||||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
static const Duration _cacheTtl = Duration(hours: 24);
|
||||||
|
static const Duration traceCacheTtl = _cacheTtl;
|
||||||
static const Duration traceTimeout = Duration(seconds: 30);
|
static const Duration traceTimeout = Duration(seconds: 30);
|
||||||
|
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||||
|
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
static Future<void>? _ongoingSync;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
Duration cacheTtl = _cacheTtl,
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
bool allowNetwork = true,
|
||||||
|
http.Client? client,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
cached.isNotEmpty &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
if (!allowNetwork) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await (client ?? http.Client())
|
||||||
.get(Uri.parse(_nodesEndpoint))
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
.timeout(traceTimeout);
|
.timeout(traceTimeout);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
@@ -69,8 +82,133 @@ class MeshMapNodesService {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
await _storeCache(nodes, cachedAt: now);
|
||||||
_cachedAt = now;
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
final cachedJson = prefs.getString(_cacheKey);
|
||||||
|
if (cachedAtMs == null || cachedJson == null) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
if (now.difference(cachedAt) >= cacheTtl) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||||
|
final nodes = decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where(
|
||||||
|
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||||
|
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
return nodes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> syncInBackgroundIfStale({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
if (cached.isNotEmpty && _cachedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ongoingSync != null) {
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ongoingSync = () async {
|
||||||
|
try {
|
||||||
|
await fetchNodes(
|
||||||
|
forceRefresh: true,
|
||||||
|
cacheTtl: cacheTtl,
|
||||||
|
allowNetwork: true,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Background refresh is best-effort only.
|
||||||
|
} finally {
|
||||||
|
_ongoingSync = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
await prefs.remove(_cacheTimestampKey);
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> cachedAt() async {
|
||||||
|
if (_cachedAt != null) return _cachedAt;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
if (cachedAtMs == null) return null;
|
||||||
|
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
return _cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
|
||||||
|
|
||||||
|
static Future<void> _storeCache(
|
||||||
|
List<MeshMapNode> nodes, {
|
||||||
|
required DateTime cachedAt,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_cacheKey,
|
||||||
|
jsonEncode(
|
||||||
|
nodes
|
||||||
|
.map(
|
||||||
|
(node) => {
|
||||||
|
'type': node.type,
|
||||||
|
'name': node.name,
|
||||||
|
'public_key': node.publicKey,
|
||||||
|
'latitude': node.latitude,
|
||||||
|
'longitude': node.longitude,
|
||||||
|
'updated_at': node.updatedAtMs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
|
|||||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
||||||
|
|
||||||
/// Destination types
|
/// Destination types
|
||||||
|
static const String destinationTypeAll = 'all';
|
||||||
static const String destinationTypeChannel = 'channel';
|
static const String destinationTypeChannel = 'channel';
|
||||||
static const String destinationTypeContact = 'contact';
|
static const String destinationTypeContact = 'contact';
|
||||||
static const String destinationTypeRoom = 'room';
|
static const String destinationTypeRoom = 'room';
|
||||||
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
|
|||||||
|
|
||||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||||
|
|
||||||
return {
|
return {'type': type, 'publicKey': ?publicKey};
|
||||||
'type': type,
|
|
||||||
'publicKey': ?publicKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the selected destination
|
/// Save the selected destination
|
||||||
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
||||||
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
||||||
static Future<void> setDestination(
|
static Future<void> setDestination(
|
||||||
String type, {
|
String type, {
|
||||||
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
|
|||||||
/// Get display name for destination type
|
/// Get display name for destination type
|
||||||
static String getDestinationTypeName(String type) {
|
static String getDestinationTypeName(String type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case destinationTypeAll:
|
||||||
|
return 'All';
|
||||||
case destinationTypeChannel:
|
case destinationTypeChannel:
|
||||||
return 'Channel';
|
return 'Channel';
|
||||||
case destinationTypeContact:
|
case destinationTypeContact:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../models/message.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
@@ -16,6 +17,10 @@ class MessageStorageService {
|
|||||||
'stored_message_reception_details';
|
'stored_message_reception_details';
|
||||||
static const String _messageTransferDetailsKey =
|
static const String _messageTransferDetailsKey =
|
||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
|
static const String _messageRouteMetadataKey =
|
||||||
|
'stored_message_route_metadata';
|
||||||
|
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||||
|
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
@@ -24,12 +29,21 @@ class MessageStorageService {
|
|||||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||||
|
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
// survive restore even if the sidecar reception-details entry is absent.
|
||||||
|
final jsonList = messages
|
||||||
|
.map(
|
||||||
|
(msg) => _messageToJson(
|
||||||
|
msg,
|
||||||
|
receptionDetails: messageReceptionDetails[msg.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Limit to max stored messages (keep most recent)
|
// Limit to max stored messages (keep most recent)
|
||||||
final limitedList = jsonList.length > _maxStoredMessages
|
final limitedList = jsonList.length > _maxStoredMessages
|
||||||
@@ -44,6 +58,7 @@ class MessageStorageService {
|
|||||||
final locationJson = <String, dynamic>{};
|
final locationJson = <String, dynamic>{};
|
||||||
final receptionJson = <String, dynamic>{};
|
final receptionJson = <String, dynamic>{};
|
||||||
final transferJson = <String, dynamic>{};
|
final transferJson = <String, dynamic>{};
|
||||||
|
final routeMetadataJson = <String, dynamic>{};
|
||||||
for (final entry in messageContactLocations.entries) {
|
for (final entry in messageContactLocations.entries) {
|
||||||
if (retainedMessageIds.contains(entry.key)) {
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
locationJson[entry.key] = entry.value.toJson();
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
@@ -59,6 +74,11 @@ class MessageStorageService {
|
|||||||
transferJson[entry.key] = entry.value.toJson();
|
transferJson[entry.key] = entry.value.toJson();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (final entry in messageRouteMetadata.entries) {
|
||||||
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
|
routeMetadataJson[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_messageContactLocationsKey,
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
@@ -71,6 +91,10 @@ class MessageStorageService {
|
|||||||
_messageTransferDetailsKey,
|
_messageTransferDetailsKey,
|
||||||
jsonEncode(transferJson),
|
jsonEncode(transferJson),
|
||||||
);
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageRouteMetadataKey,
|
||||||
|
jsonEncode(routeMetadataJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -115,16 +139,10 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = <String, MessageReceptionDetails>{};
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
if (jsonString != null && jsonString.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if (value is! Map<String, dynamic>) continue;
|
if (value is! Map<String, dynamic>) continue;
|
||||||
@@ -133,6 +151,24 @@ class MessageStorageService {
|
|||||||
result[entry.key] = snapshot;
|
result[entry.key] = snapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
|
||||||
|
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||||
|
result.putIfAbsent(messageId, () => snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
|
||||||
|
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||||
|
result.putIfAbsent(
|
||||||
|
messageId,
|
||||||
|
() => MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||||
@@ -170,6 +206,32 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messageRouteMetadataKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageRouteMetadata>{};
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
result[entry.key] = MessageRouteMetadata.fromJson(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load messages from persistent storage
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -206,6 +268,7 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
@@ -237,7 +300,10 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Message to JSON
|
/// Convert Message to JSON
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
Map<String, dynamic> _messageToJson(
|
||||||
|
Message message, {
|
||||||
|
MessageReceptionDetails? receptionDetails,
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -297,9 +363,67 @@ class MessageStorageService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (receptionDetails != null)
|
||||||
|
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||||
|
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||||
|
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageReceptionDetails>>
|
||||||
|
_loadEmbeddedReceptionDetails() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||||
|
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||||
|
if (snapshot == null) continue;
|
||||||
|
result[messageId] = snapshot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_messagesKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<int>>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final pathBytes = entry[_legacyPathBytesKey];
|
||||||
|
if (messageId is! String || pathBytes is! List) continue;
|
||||||
|
final normalized = pathBytes
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList();
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
result[messageId] = normalized;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert JSON to Message
|
/// Convert JSON to Message
|
||||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
46
lib/services/messaging_route_preferences.dart
Normal file
46
lib/services/messaging_route_preferences.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class MessagingRoutePreferences {
|
||||||
|
static const bool defaultAutoRouteRotationEnabled = false;
|
||||||
|
static const bool defaultClearPathOnMaxRetry = false;
|
||||||
|
static const bool defaultNearestRelayFallbackEnabled = true;
|
||||||
|
|
||||||
|
static const String _autoRouteRotationKey =
|
||||||
|
'messaging_auto_route_rotation_enabled';
|
||||||
|
static const String _clearPathOnMaxRetryKey =
|
||||||
|
'messaging_clear_path_on_max_retry';
|
||||||
|
static const String _nearestRelayFallbackKey =
|
||||||
|
'messaging_nearest_relay_fallback_enabled';
|
||||||
|
|
||||||
|
static Future<bool> getAutoRouteRotationEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_autoRouteRotationKey) ??
|
||||||
|
defaultAutoRouteRotationEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_autoRouteRotationKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getClearPathOnMaxRetry() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> getNearestRelayFallbackEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(_nearestRelayFallbackKey) ??
|
||||||
|
defaultNearestRelayFallbackEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_nearestRelayFallbackKey, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/services/nearest_router_selector.dart
Normal file
69
lib/services/nearest_router_selector.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class NearestRouterSelector {
|
||||||
|
const NearestRouterSelector();
|
||||||
|
|
||||||
|
Contact? select({
|
||||||
|
required Position? senderPosition,
|
||||||
|
required List<Contact> repeaters,
|
||||||
|
required Contact recipient,
|
||||||
|
}) {
|
||||||
|
if (senderPosition == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eligible = repeaters.where((contact) {
|
||||||
|
if (contact.publicKeyHex == recipient.publicKeyHex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!contact.isRecentlySeen) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return contact.displayLocation != null;
|
||||||
|
}).toList();
|
||||||
|
if (eligible.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
eligible.sort((a, b) {
|
||||||
|
final locationA = a.displayLocation!;
|
||||||
|
final locationB = b.displayLocation!;
|
||||||
|
final distanceA = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationA.latitude,
|
||||||
|
locationA.longitude,
|
||||||
|
);
|
||||||
|
final distanceB = Geolocator.distanceBetween(
|
||||||
|
senderPosition.latitude,
|
||||||
|
senderPosition.longitude,
|
||||||
|
locationB.latitude,
|
||||||
|
locationB.longitude,
|
||||||
|
);
|
||||||
|
final distanceCompare = distanceA.compareTo(distanceB);
|
||||||
|
if (distanceCompare != 0) {
|
||||||
|
return distanceCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
|
||||||
|
if (advertCompare != 0) {
|
||||||
|
return advertCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
|
||||||
|
if (hopCompare != 0) {
|
||||||
|
return hopCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nameCompare = a.advName.compareTo(b.advName);
|
||||||
|
if (nameCompare != 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
return a.publicKeyHex.compareTo(b.publicKeyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return eligible.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,6 @@ class NotificationService {
|
|||||||
requestAlertPermission: true,
|
requestAlertPermission: true,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: true,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: true,
|
||||||
requestCriticalPermission: true, // For urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined initialization settings
|
// Combined initialization settings
|
||||||
@@ -100,8 +99,6 @@ class NotificationService {
|
|||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
critical:
|
|
||||||
true, // Request critical alert permission for urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
_permissionGranted = granted ?? false;
|
_permissionGranted = granted ?? false;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -274,8 +271,7 @@ class NotificationService {
|
|||||||
badgeNumber: 1,
|
badgeNumber: 1,
|
||||||
threadIdentifier: 'sar_markers',
|
threadIdentifier: 'sar_markers',
|
||||||
categoryIdentifier: 'SAR_ALERT',
|
categoryIdentifier: 'SAR_ALERT',
|
||||||
interruptionLevel:
|
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||||
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined notification details
|
// Combined notification details
|
||||||
@@ -406,10 +402,13 @@ class NotificationService {
|
|||||||
(DateTime.now().millisecondsSinceEpoch % 1000);
|
(DateTime.now().millisecondsSinceEpoch % 1000);
|
||||||
|
|
||||||
// Build notification title and body
|
// Build notification title and body
|
||||||
|
final resolvedChannelName = channelName?.trim().isNotEmpty == true
|
||||||
|
? channelName!.trim()
|
||||||
|
: (localizations?.publicChannel ?? 'Public');
|
||||||
final title = isChannelMessage
|
final title = isChannelMessage
|
||||||
? (localizations != null
|
? (localizations != null
|
||||||
? '${localizations.channel}: ${channelName ?? "Public"}'
|
? '${localizations.channel}: $resolvedChannelName'
|
||||||
: 'Channel: ${channelName ?? "Public"}')
|
: 'Channel: $resolvedChannelName')
|
||||||
: (localizations != null
|
: (localizations != null
|
||||||
? '${localizations.newMessage} ${localizations.from} $senderName'
|
? '${localizations.newMessage} ${localizations.from} $senderName'
|
||||||
: 'New message from $senderName');
|
: 'New message from $senderName');
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
|||||||
File? _file;
|
File? _file;
|
||||||
|
|
||||||
Future<File> _resolveFile() async {
|
Future<File> _resolveFile() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||||
|
}
|
||||||
if (_file != null) return _file!;
|
if (_file != null) return _file!;
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final file = File('${dir.path}/$_fileName');
|
final file = File('${dir.path}/$_fileName');
|
||||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
|||||||
|
|
||||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||||
if (logs.isEmpty) return;
|
if (logs.isEmpty) return;
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
final sink = file.openWrite(mode: FileMode.append);
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||||
|
if (kIsWeb) return const [];
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return const [];
|
if (!await file.exists()) return const [];
|
||||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> count() async {
|
Future<int> count() async {
|
||||||
|
if (kIsWeb) return 0;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return 0;
|
if (!await file.exists()) return 0;
|
||||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
282
lib/services/path_history_service.dart
Normal file
282
lib/services/path_history_service.dart
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../models/path_history.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
class PathHistoryService {
|
||||||
|
static const String _storageKey = 'contact_path_history_v1';
|
||||||
|
static const int _maxDirectPaths = 20;
|
||||||
|
static const int _topRotationCount = 3;
|
||||||
|
|
||||||
|
final Map<String, ContactPathHistory> _cache = {};
|
||||||
|
bool _isLoaded = false;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
if (_isLoaded) return;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_storageKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
_isLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value;
|
||||||
|
if (value is Map<String, dynamic>) {
|
||||||
|
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
|
||||||
|
}
|
||||||
|
_isLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordLearnedPath(Contact contact) async {
|
||||||
|
await initialize();
|
||||||
|
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final signature = _signature(contact.routePathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: contact.routePathBytes.toList(),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
|
successCount: existing?.successCount ?? 0,
|
||||||
|
failureCount: existing?.failureCount ?? 0,
|
||||||
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveHistory(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordReceivedBytePath(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
List<int> pathBytes,
|
||||||
|
int hashSize,
|
||||||
|
) async {
|
||||||
|
await initialize();
|
||||||
|
if (pathBytes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hashSize < 1 || hashSize > 3) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathBytes.length % hashSize != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||||
|
pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
|
final signature = normalizedPathBytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: normalizedPathBytes,
|
||||||
|
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||||
|
hashSize: hashSize,
|
||||||
|
source: PathRecordSource.observed,
|
||||||
|
successCount: existing?.successCount ?? 0,
|
||||||
|
failureCount: existing?.failureCount ?? 0,
|
||||||
|
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PathSelection> getSelectionForContact(
|
||||||
|
Contact contact, {
|
||||||
|
required bool autoRouteRotationEnabled,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
await recordLearnedPath(contact);
|
||||||
|
|
||||||
|
if (contact.routeHasPath && contact.routeHopCount > 0) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: PathSelectionMode.directCurrent,
|
||||||
|
pathBytes: Uint8List.fromList(contact.routePathBytes),
|
||||||
|
hopCount: contact.routeHopCount,
|
||||||
|
hashSize: contact.routeHashSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoRouteRotationEnabled) {
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final history = _historyFor(contact.publicKeyHex);
|
||||||
|
final ranked = List<PathRecord>.from(history.directPaths)
|
||||||
|
..sort(_comparePathRecords);
|
||||||
|
final topPaths = ranked.take(_topRotationCount).toList();
|
||||||
|
if (topPaths.isEmpty) {
|
||||||
|
final nextFloodHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
|
||||||
|
return PathSelection.flood();
|
||||||
|
}
|
||||||
|
|
||||||
|
final selections =
|
||||||
|
topPaths
|
||||||
|
.map(
|
||||||
|
(record) => PathSelection(
|
||||||
|
mode: PathSelectionMode.directHistorical,
|
||||||
|
pathBytes: Uint8List.fromList(record.pathBytes),
|
||||||
|
hopCount: record.hopCount,
|
||||||
|
hashSize: record.hashSize,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..add(PathSelection.flood());
|
||||||
|
|
||||||
|
final index = history.rotationIndex % selections.length;
|
||||||
|
final updatedHistory = history.copyWith(
|
||||||
|
rotationIndex: history.rotationIndex + 1,
|
||||||
|
);
|
||||||
|
await _saveHistory(contact.publicKeyHex, updatedHistory);
|
||||||
|
return selections[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordPathResult(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool success,
|
||||||
|
int? roundTripTimeMs,
|
||||||
|
}) async {
|
||||||
|
await initialize();
|
||||||
|
final history = _historyFor(contactPublicKeyHex);
|
||||||
|
if (selection.usesFlood) {
|
||||||
|
final current = history.floodStats;
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
floodStats: current.copyWith(
|
||||||
|
successCount: current.successCount + (success ? 1 : 0),
|
||||||
|
failureCount: current.failureCount + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
|
||||||
|
: current.lastRoundTripTimeMs,
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final signature = _signature(selection.pathBytes);
|
||||||
|
final existing = _findDirectPath(history.directPaths, signature);
|
||||||
|
final updated = PathRecord(
|
||||||
|
pathBytes: selection.pathBytes.toList(),
|
||||||
|
hopCount: selection.hopCount,
|
||||||
|
hashSize: selection.hashSize,
|
||||||
|
source: existing?.source ?? PathRecordSource.learned,
|
||||||
|
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
|
||||||
|
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
|
||||||
|
lastRoundTripTimeMs: success
|
||||||
|
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
|
||||||
|
: (existing?.lastRoundTripTimeMs ?? 0),
|
||||||
|
lastUsedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await _saveHistory(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
history.copyWith(
|
||||||
|
directPaths: _upsertDirectPath(history.directPaths, updated),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache[contactPublicKeyHex] ??
|
||||||
|
ContactPathHistory.empty(contactPublicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContactPathHistory _historyFor(String contactPublicKeyHex) {
|
||||||
|
return _cache.putIfAbsent(
|
||||||
|
contactPublicKeyHex,
|
||||||
|
() => ContactPathHistory.empty(contactPublicKeyHex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHistory(
|
||||||
|
String contactPublicKeyHex,
|
||||||
|
ContactPathHistory history,
|
||||||
|
) async {
|
||||||
|
_cache[contactPublicKeyHex] = history;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
for (final entry in _cache.entries) {
|
||||||
|
payload[entry.key] = entry.value.toJson();
|
||||||
|
}
|
||||||
|
await prefs.setString(_storageKey, jsonEncode(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PathRecord> _upsertDirectPath(
|
||||||
|
List<PathRecord> existing,
|
||||||
|
PathRecord updatedRecord,
|
||||||
|
) {
|
||||||
|
final updated = List<PathRecord>.from(existing)
|
||||||
|
..removeWhere((record) => record.signature == updatedRecord.signature)
|
||||||
|
..insert(0, updatedRecord);
|
||||||
|
if (updated.length > _maxDirectPaths) {
|
||||||
|
return updated.take(_maxDirectPaths).toList();
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _comparePathRecords(PathRecord a, PathRecord b) {
|
||||||
|
final successRateCompare = b.successRate.compareTo(a.successRate);
|
||||||
|
if (successRateCompare != 0) return successRateCompare;
|
||||||
|
|
||||||
|
final successCountCompare = b.successCount.compareTo(a.successCount);
|
||||||
|
if (successCountCompare != 0) return successCountCompare;
|
||||||
|
|
||||||
|
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
|
||||||
|
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
|
||||||
|
final rttCompare = aRtt.compareTo(bRtt);
|
||||||
|
if (rttCompare != 0) return rttCompare;
|
||||||
|
|
||||||
|
return b.lastUsedAt.compareTo(a.lastUsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
|
||||||
|
for (final record in records) {
|
||||||
|
if (record.signature == signature) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature(Uint8List bytes) =>
|
||||||
|
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
26
lib/services/route_hash_preferences.dart
Normal file
26
lib/services/route_hash_preferences.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class RouteHashPreferences {
|
||||||
|
static const String _hashSizeKey = 'route_hash_size';
|
||||||
|
static const int defaultHashSize = 1;
|
||||||
|
|
||||||
|
static Future<int> getHashSize() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
|
||||||
|
return _normalize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setHashSize(int value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_hashSizeKey, _normalize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int normalizeSync(int value) => _normalize(value);
|
||||||
|
|
||||||
|
static int _normalize(int value) {
|
||||||
|
if (value < 1 || value > 3) {
|
||||||
|
return defaultHashSize;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
|
|
||||||
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
|
|
||||||
import 'package:mbtiles/mbtiles.dart';
|
|
||||||
import '../models/map_layer.dart';
|
|
||||||
|
|
||||||
class TileCacheService {
|
|
||||||
static const String _storeName = 'meshcore_sar_tiles';
|
|
||||||
|
|
||||||
// Global flag to ensure ObjectBox is only initialized once
|
|
||||||
static bool _objectBoxInitialized = false;
|
|
||||||
static final _initLock = <String, Future<void>>{};
|
|
||||||
|
|
||||||
late final FMTCStore _store;
|
|
||||||
bool _isInitialized = false;
|
|
||||||
bool _isDownloading = false;
|
|
||||||
|
|
||||||
Future<void> initialize() async {
|
|
||||||
if (_isInitialized) return;
|
|
||||||
|
|
||||||
// Ensure we only initialize ObjectBox once globally
|
|
||||||
if (!_objectBoxInitialized) {
|
|
||||||
// Use a lock to prevent concurrent initialization attempts
|
|
||||||
final initFuture = _initLock.putIfAbsent('objectbox', () async {
|
|
||||||
try {
|
|
||||||
await FMTCObjectBoxBackend().initialise();
|
|
||||||
_objectBoxInitialized = true;
|
|
||||||
} catch (e) {
|
|
||||||
// Already initialized or error - that's okay
|
|
||||||
_objectBoxInitialized = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await initFuture;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
_store = FMTCStore(_storeName);
|
|
||||||
await _store.manage.create();
|
|
||||||
_isInitialized = true;
|
|
||||||
} catch (e) {
|
|
||||||
// Store might already exist
|
|
||||||
_store = FMTCStore(_storeName);
|
|
||||||
_isInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FMTCTileProvider getTileProvider(MapLayer layer) {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return FMTCTileProvider(
|
|
||||||
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
|
|
||||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
|
||||||
cachedValidDuration: const Duration(days: 30),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get tile provider for WMS layers with caching support
|
|
||||||
/// WMS layers require special handling because they use WMSTileLayerOptions
|
|
||||||
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!layer.isWms) {
|
|
||||||
throw ArgumentError('Layer must be a WMS layer');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the same cached tile provider
|
|
||||||
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
|
|
||||||
return FMTCTileProvider(
|
|
||||||
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
|
|
||||||
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
|
|
||||||
cachedValidDuration: const Duration(days: 30),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> downloadRegion({
|
|
||||||
required MapLayer layer,
|
|
||||||
required LatLngBounds bounds,
|
|
||||||
required int minZoom,
|
|
||||||
required int maxZoom,
|
|
||||||
Function(double progress)? onProgress,
|
|
||||||
}) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isDownloading) {
|
|
||||||
throw StateError('A download is already in progress. Cancel it first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
_isDownloading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final region = RectangleRegion(bounds);
|
|
||||||
|
|
||||||
final downloadable = region.toDownloadable(
|
|
||||||
minZoom: minZoom,
|
|
||||||
maxZoom: maxZoom,
|
|
||||||
options: TileLayer(urlTemplate: layer.urlTemplate),
|
|
||||||
);
|
|
||||||
|
|
||||||
final download = _store.download.startForeground(region: downloadable);
|
|
||||||
|
|
||||||
await for (final progress in download.downloadProgress) {
|
|
||||||
if (onProgress != null && progress.maxTilesCount > 0) {
|
|
||||||
// Use attemptedTilesCount instead of successfulTilesCount
|
|
||||||
// attemptedTilesCount includes successful + buffered + skipped tiles
|
|
||||||
final percentage = progress.percentageProgress;
|
|
||||||
debugPrint(
|
|
||||||
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
|
|
||||||
);
|
|
||||||
onProgress(percentage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_isDownloading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cancelDownload() async {
|
|
||||||
if (!_isInitialized) return;
|
|
||||||
await _store.download.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearCache() async {
|
|
||||||
if (!_isInitialized) return;
|
|
||||||
await _store.manage.delete();
|
|
||||||
await _store.manage.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> getCachedTileCount() async {
|
|
||||||
if (!_isInitialized) return 0;
|
|
||||||
final stats = await _store.stats.length;
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<double> getCacheSizeMB() async {
|
|
||||||
if (!_isInitialized) return 0.0;
|
|
||||||
final stats = await _store.stats.size;
|
|
||||||
return stats / (1024 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> getAvailableStores() async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final stores = await FMTCRoot.stats.storesAvailable;
|
|
||||||
return stores.map((store) => store.storeName).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getStoreStats() async {
|
|
||||||
if (!_isInitialized) return {};
|
|
||||||
|
|
||||||
final length = await _store.stats.length;
|
|
||||||
final size = await _store.stats.all.then((a) => a.size);
|
|
||||||
|
|
||||||
return {
|
|
||||||
'tileCount': length,
|
|
||||||
'sizeMB': size / 1024,
|
|
||||||
'storeName': _storeName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get vector tile provider for MBTiles layers
|
|
||||||
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
|
|
||||||
if (!layer.isVector || layer.mbtilesFile == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final mbtiles = MbTiles(
|
|
||||||
mbtilesPath: layer.mbtilesFile!.path,
|
|
||||||
gzip: layer.isGzipped ?? false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MbTilesVectorTileProvider(
|
|
||||||
mbtiles: mbtiles,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error creating vector tile provider: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export the current tile cache store to an archive file
|
|
||||||
///
|
|
||||||
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
|
|
||||||
///
|
|
||||||
/// Returns the number of tiles exported
|
|
||||||
Future<int> exportStore(String outputPath) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: outputPath);
|
|
||||||
final result = await external.export(storeNames: [_storeName]);
|
|
||||||
|
|
||||||
debugPrint('Export completed: $result tiles exported to $outputPath');
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error exporting store: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import a tile cache store from an archive file
|
|
||||||
///
|
|
||||||
/// [filePath] - Path to the .fmtc archive file to import
|
|
||||||
/// [storeNames] - Optional list of store names to import (null = import all)
|
|
||||||
/// [strategy] - Conflict resolution strategy (default: merge)
|
|
||||||
///
|
|
||||||
/// Returns a map with import statistics (e.g., tile count, stores imported)
|
|
||||||
Future<Map<String, dynamic>> importStore(
|
|
||||||
String filePath, {
|
|
||||||
List<String>? storeNames,
|
|
||||||
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
|
|
||||||
}) async {
|
|
||||||
if (!_isInitialized) {
|
|
||||||
throw StateError(
|
|
||||||
'TileCacheService not initialized. Call initialize() first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: filePath);
|
|
||||||
final result = external.import(storeNames: storeNames, strategy: strategy);
|
|
||||||
|
|
||||||
// Wait for the import to complete and get tile count
|
|
||||||
final tileCount = await result.complete;
|
|
||||||
|
|
||||||
// Wait for store states
|
|
||||||
final storesToStates = await result.storesToStates;
|
|
||||||
|
|
||||||
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
|
|
||||||
|
|
||||||
// Count successful stores (those that weren't skipped)
|
|
||||||
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
'successfulStores': successfulCount,
|
|
||||||
'tileCount': tileCount,
|
|
||||||
'storesToStates': storesToStates,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error importing store: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all stores available in an archive file without importing
|
|
||||||
///
|
|
||||||
/// [filePath] - Path to the .fmtc archive file to inspect
|
|
||||||
///
|
|
||||||
/// Returns a list of store names contained in the archive
|
|
||||||
Future<List<String>> listArchiveStores(String filePath) async {
|
|
||||||
try {
|
|
||||||
final external = FMTCRoot.external(pathToArchive: filePath);
|
|
||||||
final stores = await external.listStores;
|
|
||||||
debugPrint('Archive contains ${stores.length} stores: $stores');
|
|
||||||
return stores;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error listing archive stores: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_isInitialized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
lib/services/voice_codec_preferences.dart
Normal file
21
lib/services/voice_codec_preferences.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
class VoiceCodecPreferences {
|
||||||
|
static const String _codecKey = 'voice_codec';
|
||||||
|
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
||||||
|
|
||||||
|
static Future<VoiceCodecKind> getCodec() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_codecKey);
|
||||||
|
return VoiceCodecKind.values.firstWhere(
|
||||||
|
(codec) => codec.name == raw,
|
||||||
|
orElse: () => defaultCodec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setCodec(VoiceCodecKind codec) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_codecKey, codec.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|
||||||
|
|
||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +43,11 @@ class VoiceCodecService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
|
||||||
/// Returns the raw Codec2-encoded bytes.
|
|
||||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
|
||||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||||
@@ -59,24 +60,14 @@ class VoiceCodecService {
|
|||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) async {
|
) async {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
final c2Mode = codec2ModeFor(mode);
|
|
||||||
final c2 = Codec2.create(c2Mode);
|
|
||||||
final spf = c2.samplesPerFrame;
|
|
||||||
c2.destroy();
|
|
||||||
|
|
||||||
// Estimate total samples (use actual data or silence per missing packet)
|
|
||||||
final all = <Int16List>[];
|
final all = <Int16List>[];
|
||||||
for (final pkt in packets) {
|
for (final pkt in packets) {
|
||||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||||
// Silence for missing packet — duration approximated by mode
|
all.add(Int16List(mode.samplesPerPacket));
|
||||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
|
||||||
all.add(Int16List(silenceSamples));
|
|
||||||
} else {
|
} else {
|
||||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
all.add(await decode(pkt.codec2Data, mode));
|
||||||
all.add(decoded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||||
final result = Int16List(total);
|
final result = Int16List(total);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
|
||||||
enum Codec2Mode {
|
enum Codec2Mode {
|
||||||
mode3200(0),
|
mode3200(0),
|
||||||
mode2400(1),
|
mode2400(1),
|
||||||
@@ -19,13 +18,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
int get bytesPerSecond {
|
int get bytesPerSecond {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case mode3200: return 400;
|
case mode3200:
|
||||||
case mode700c: return 100;
|
return 400;
|
||||||
case mode1200: return 150;
|
case mode700c:
|
||||||
case mode1300: return 175;
|
return 100;
|
||||||
case mode1400: return 175;
|
case mode1200:
|
||||||
case mode1600: return 200;
|
return 150;
|
||||||
case mode2400: return 300;
|
case mode1300:
|
||||||
|
return 175;
|
||||||
|
case mode1400:
|
||||||
|
return 175;
|
||||||
|
case mode1600:
|
||||||
|
return 200;
|
||||||
|
case mode2400:
|
||||||
|
return 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +46,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,5 @@ class VoiceCodecService {
|
|||||||
Future<Int16List> decodePackets(
|
Future<Int16List> decodePackets(
|
||||||
List<VoicePacket?> packets,
|
List<VoicePacket?> packets,
|
||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) =>
|
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
Future.error(UnsupportedError('Voice not supported on web'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
/// Plays decoded mono PCM samples by writing a WAV file
|
||||||
/// to the system temp directory and using [AudioPlayer].
|
/// to the system temp directory and using [AudioPlayer].
|
||||||
class VoicePlayerService {
|
class VoicePlayerService {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -49,16 +49,17 @@ class VoicePlayerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||||
Future<void> play(Int16List pcmSamples) async {
|
|
||||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||||
if (_isPlaying) await stop();
|
if (_isPlaying) await stop();
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
_duration = Duration(
|
||||||
|
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||||
|
);
|
||||||
_playbackStartedAt = DateTime.now();
|
_playbackStartedAt = DateTime.now();
|
||||||
_events.add(null);
|
_events.add(null);
|
||||||
|
|
||||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||||
final tmpDir = await getTemporaryDirectory();
|
final tmpDir = await getTemporaryDirectory();
|
||||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||||
await file.writeAsBytes(wavBytes);
|
await file.writeAsBytes(wavBytes);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||||
///
|
///
|
||||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||||
@@ -27,12 +27,16 @@ class VoiceRecorderService {
|
|||||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||||
Stream<Int16List> startCapture({
|
Stream<Int16List> startCapture({
|
||||||
Duration chunkDuration = const Duration(seconds: 1),
|
Duration chunkDuration = const Duration(seconds: 1),
|
||||||
|
int sampleRateHz = 8000,
|
||||||
bool enableBandPassFilter = true,
|
bool enableBandPassFilter = true,
|
||||||
bool enableCompressor = true,
|
bool enableCompressor = true,
|
||||||
bool enableLimiter = true,
|
bool enableLimiter = true,
|
||||||
|
bool enableAutoGain = false,
|
||||||
|
bool enableEchoCancellation = false,
|
||||||
|
bool enableNoiseSuppression = false,
|
||||||
}) {
|
}) {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
@@ -43,39 +47,59 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
chunkDuration,
|
chunkDuration,
|
||||||
|
sampleRateHz: sampleRateHz,
|
||||||
enableBandPassFilter: enableBandPassFilter,
|
enableBandPassFilter: enableBandPassFilter,
|
||||||
enableCompressor: enableCompressor,
|
enableCompressor: enableCompressor,
|
||||||
enableLimiter: enableLimiter,
|
enableLimiter: enableLimiter,
|
||||||
|
enableAutoGain: enableAutoGain,
|
||||||
|
enableEchoCancellation: enableEchoCancellation,
|
||||||
|
enableNoiseSuppression: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
return _controller!.stream;
|
return _controller!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording(
|
Future<void> _startRecording(
|
||||||
Duration chunkDuration, {
|
Duration chunkDuration, {
|
||||||
|
required int sampleRateHz,
|
||||||
required bool enableBandPassFilter,
|
required bool enableBandPassFilter,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
|
required bool enableAutoGain,
|
||||||
|
required bool enableEchoCancellation,
|
||||||
|
required bool enableNoiseSuppression,
|
||||||
}) async {
|
}) async {
|
||||||
final config = const RecordConfig(
|
final useBandPassFilter = enableBandPassFilter;
|
||||||
|
final useCompressor = enableCompressor;
|
||||||
|
final useLimiter = enableLimiter;
|
||||||
|
final config = RecordConfig(
|
||||||
encoder: AudioEncoder.pcm16bits,
|
encoder: AudioEncoder.pcm16bits,
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
numChannels: 1,
|
numChannels: 1,
|
||||||
bitRate: 128000, // ignored for PCM, but required by API
|
bitRate: 128000, // ignored for PCM, but required by API
|
||||||
|
autoGain: enableAutoGain,
|
||||||
|
echoCancel: enableEchoCancellation,
|
||||||
|
noiseSuppress: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = await _recorder.startStream(config);
|
final stream = await _recorder.startStream(config);
|
||||||
final voiceFilter = _VoiceBandPassFilter(
|
final voiceFilter = _VoiceBandPassFilter(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
lowCutHz: 250.0,
|
lowCutHz: 250.0,
|
||||||
highCutHz: 3400.0,
|
highCutHz: 3400.0,
|
||||||
);
|
);
|
||||||
final dynamics = _VoiceDynamicsProcessor(
|
final dynamics = _VoiceDynamicsProcessor(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
enableCompressor: enableCompressor,
|
thresholdDb: -18.0,
|
||||||
enableLimiter: enableLimiter,
|
ratio: 2.5,
|
||||||
|
attackMs: 8.0,
|
||||||
|
releaseMs: 120.0,
|
||||||
|
makeupGainDb: 4.0,
|
||||||
|
enableCompressor: useCompressor,
|
||||||
|
enableLimiter: useLimiter,
|
||||||
);
|
);
|
||||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
final chunkBytes =
|
||||||
|
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
|
|
||||||
_sub = stream.listen(
|
_sub = stream.listen(
|
||||||
@@ -85,9 +109,7 @@ class VoiceRecorderService {
|
|||||||
final chunk = buffer.sublist(0, chunkBytes);
|
final chunk = buffer.sublist(0, chunkBytes);
|
||||||
buffer.removeRange(0, chunkBytes);
|
buffer.removeRange(0, chunkBytes);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -95,9 +117,7 @@ class VoiceRecorderService {
|
|||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
final padded = _padToEven(buffer);
|
final padded = _padToEven(buffer);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
_controller?.close();
|
_controller?.close();
|
||||||
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
|
|||||||
|
|
||||||
_VoiceDynamicsProcessor({
|
_VoiceDynamicsProcessor({
|
||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
|
required double thresholdDb,
|
||||||
|
required double ratio,
|
||||||
|
required double attackMs,
|
||||||
|
required double releaseMs,
|
||||||
|
required double makeupGainDb,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: thresholdDb,
|
||||||
ratio: 2.5,
|
ratio: ratio,
|
||||||
attackMs: 8.0,
|
attackMs: attackMs,
|
||||||
releaseMs: 120.0,
|
releaseMs: releaseMs,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: makeupGainDb,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AvatarLabelHelper {
|
class AvatarLabelHelper {
|
||||||
|
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||||
|
|
||||||
static String buildLabel(String name) {
|
static String buildLabel(String name) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
|||||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
final parts = trimmed
|
final parts = _alnumChunks
|
||||||
.split(RegExp(r'[\s_-]+'))
|
.allMatches(trimmed)
|
||||||
|
.map((match) => match.group(0)!)
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final first = _take(parts[0], 1);
|
final first = _take(parts[0], 1);
|
||||||
final second = _take(parts[1], 1);
|
final second = _take(parts[1], 1);
|
||||||
return '$first$second'.toUpperCase();
|
return '$first$second'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _take(trimmed, 2).toUpperCase();
|
return _take(parts.first, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _take(String value, int count) {
|
static String _take(String value, int count) {
|
||||||
|
|||||||
116
lib/utils/contact_grouping.dart
Normal file
116
lib/utils/contact_grouping.dart
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class InferredContactGroup {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
|
||||||
|
const InferredContactGroup({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.contacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactListItem {
|
||||||
|
final Contact? contact;
|
||||||
|
final InferredContactGroup? group;
|
||||||
|
|
||||||
|
const ContactListItem._({this.contact, this.group});
|
||||||
|
|
||||||
|
const ContactListItem.contact(Contact contact) : this._(contact: contact);
|
||||||
|
|
||||||
|
const ContactListItem.group(InferredContactGroup group)
|
||||||
|
: this._(group: group);
|
||||||
|
|
||||||
|
bool get isGroup => group != null;
|
||||||
|
|
||||||
|
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactGrouping {
|
||||||
|
static final RegExp _prefixedNamePattern = RegExp(
|
||||||
|
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||||
|
return List<Contact>.from(contacts)
|
||||||
|
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ContactListItem> buildItems(
|
||||||
|
List<Contact> contacts, {
|
||||||
|
int minGroupSize = 4,
|
||||||
|
}) {
|
||||||
|
final sortedContacts = sortByLastSeen(contacts);
|
||||||
|
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ContactListItem> buildItemsFromSorted(
|
||||||
|
List<Contact> sortedContacts, {
|
||||||
|
int minGroupSize = 4,
|
||||||
|
}) {
|
||||||
|
final groupedContacts = <String, List<Contact>>{};
|
||||||
|
final groupLabels = <String, String>{};
|
||||||
|
|
||||||
|
for (final contact in sortedContacts) {
|
||||||
|
final prefix = _extractPrefix(contact.displayName);
|
||||||
|
if (prefix == null) continue;
|
||||||
|
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
|
||||||
|
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
final eligibleGroups = <String, InferredContactGroup>{};
|
||||||
|
for (final entry in groupedContacts.entries) {
|
||||||
|
if (entry.value.length < minGroupSize) continue;
|
||||||
|
eligibleGroups[entry.key] = InferredContactGroup(
|
||||||
|
key: entry.key,
|
||||||
|
label: groupLabels[entry.key] ?? entry.key,
|
||||||
|
contacts: entry.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final emittedGroups = <String>{};
|
||||||
|
final items = <ContactListItem>[];
|
||||||
|
|
||||||
|
for (final contact in sortedContacts) {
|
||||||
|
final prefix = _extractPrefix(contact.displayName);
|
||||||
|
final group = prefix == null ? null : eligibleGroups[prefix.key];
|
||||||
|
if (group == null) {
|
||||||
|
items.add(ContactListItem.contact(contact));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (emittedGroups.add(group.key)) {
|
||||||
|
items.add(ContactListItem.group(group));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _GroupPrefix? _extractPrefix(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final match = _prefixedNamePattern.firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final rawPrefix = match.group(1);
|
||||||
|
final separator = match.group(2);
|
||||||
|
if (rawPrefix == null || separator == null) return null;
|
||||||
|
|
||||||
|
return _GroupPrefix(
|
||||||
|
key: rawPrefix.toUpperCase(),
|
||||||
|
label: '$rawPrefix$separator',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupPrefix {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _GroupPrefix({required this.key, required this.label});
|
||||||
|
}
|
||||||
69
lib/utils/fast_gps_packet.dart
Normal file
69
lib/utils/fast_gps_packet.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class FastGpsPacket {
|
||||||
|
static const int magic = 0x47; // 'G'
|
||||||
|
static const int _payloadLength = 19;
|
||||||
|
// Store coordinates in microdegrees. This preserves sub-meter precision,
|
||||||
|
// which comfortably satisfies the meter-accuracy requirement.
|
||||||
|
static const double coordinateScale = 1e6;
|
||||||
|
|
||||||
|
final String senderKey6;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final int timestampSeconds;
|
||||||
|
|
||||||
|
const FastGpsPacket({
|
||||||
|
required this.senderKey6,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.timestampSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool isFastGpsBinary(Uint8List payload) =>
|
||||||
|
payload.length == _payloadLength && payload[0] == magic;
|
||||||
|
|
||||||
|
static FastGpsPacket? tryParseBinary(Uint8List payload) {
|
||||||
|
if (!isFastGpsBinary(payload)) return null;
|
||||||
|
|
||||||
|
final key6 = payload
|
||||||
|
.sublist(1, 7)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
final data = ByteData.sublistView(payload);
|
||||||
|
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
|
||||||
|
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
|
||||||
|
final timestampSeconds = data.getUint32(15, Endian.little);
|
||||||
|
|
||||||
|
if (!_isValidCoordinate(latitude, longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FastGpsPacket(
|
||||||
|
senderKey6: key6,
|
||||||
|
latitude: latitude,
|
||||||
|
longitude: longitude,
|
||||||
|
timestampSeconds: timestampSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List encodeBinary() {
|
||||||
|
final out = Uint8List(_payloadLength);
|
||||||
|
final data = ByteData.sublistView(out);
|
||||||
|
out[0] = magic;
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
|
}
|
||||||
|
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
|
||||||
|
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
|
||||||
|
data.setUint32(15, timestampSeconds, Endian.little);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isValidCoordinate(double latitude, double longitude) {
|
||||||
|
if (!latitude.isFinite || !longitude.isFinite) return false;
|
||||||
|
return latitude >= -90.0 &&
|
||||||
|
latitude <= 90.0 &&
|
||||||
|
longitude >= -180.0 &&
|
||||||
|
longitude <= 180.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,38 +4,55 @@ import '../models/contact.dart';
|
|||||||
|
|
||||||
class DecodedLogRxRoute {
|
class DecodedLogRxRoute {
|
||||||
final int payloadType;
|
final int payloadType;
|
||||||
final List<int> pathHashes;
|
final int pathDescriptor;
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hashSize;
|
||||||
|
|
||||||
const DecodedLogRxRoute({
|
const DecodedLogRxRoute({
|
||||||
required this.payloadType,
|
required this.payloadType,
|
||||||
required this.pathHashes,
|
required this.pathDescriptor,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hashSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
|
List<String> get hopHashes =>
|
||||||
|
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
|
||||||
|
|
||||||
|
int get hopCount => hopHashes.length;
|
||||||
|
|
||||||
|
String? get originalSenderHashHex =>
|
||||||
|
hopHashes.isEmpty ? null : hopHashes.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResolvedNodeHash {
|
class ResolvedNodeHash {
|
||||||
final int hash;
|
final String hashHex;
|
||||||
final String label;
|
final String label;
|
||||||
final bool isOwnNode;
|
final bool isOwnNode;
|
||||||
final bool isUniqueMatch;
|
final bool isUniqueMatch;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
|
final double? latitude;
|
||||||
|
final double? longitude;
|
||||||
|
|
||||||
const ResolvedNodeHash({
|
const ResolvedNodeHash({
|
||||||
required this.hash,
|
required this.hashHex,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isOwnNode,
|
required this.isOwnNode,
|
||||||
required this.isUniqueMatch,
|
required this.isUniqueMatch,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
|
this.latitude,
|
||||||
|
this.longitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
|
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class LogRxRouteDecoder {
|
class LogRxRouteDecoder {
|
||||||
const LogRxRouteDecoder._();
|
const LogRxRouteDecoder._();
|
||||||
|
|
||||||
static DecodedLogRxRoute? decode(Uint8List rawData) {
|
static DecodedLogRxRoute? decode(
|
||||||
|
Uint8List rawData, {
|
||||||
|
int? preferredHashSize,
|
||||||
|
}) {
|
||||||
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
||||||
|
|
||||||
final rawPacketData = rawData.sublist(3);
|
final rawPacketData = rawData.sublist(3);
|
||||||
@@ -52,44 +69,147 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rawPacketData.length <= index) return null;
|
if (rawPacketData.length <= index) return null;
|
||||||
final pathLen = rawPacketData[index++];
|
final pathDescriptor = rawPacketData[index++];
|
||||||
if (rawPacketData.length < index + pathLen) return null;
|
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||||
|
final pathByteLen = pathMode == 0
|
||||||
|
? pathDescriptor
|
||||||
|
: descriptorByteLength(pathDescriptor);
|
||||||
|
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
|
||||||
|
final hashSize = pathMode == 0
|
||||||
|
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
|
||||||
|
: (descriptorHashSize(pathDescriptor) ??
|
||||||
|
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
|
||||||
|
|
||||||
return DecodedLogRxRoute(
|
return DecodedLogRxRoute(
|
||||||
payloadType: payloadType,
|
payloadType: payloadType,
|
||||||
pathHashes: rawPacketData.sublist(index, index + pathLen),
|
pathDescriptor: pathDescriptor,
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
hashSize: hashSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? descriptorHashSize(int pathDescriptor) {
|
||||||
|
final normalized = pathDescriptor & 0xFF;
|
||||||
|
final mode = normalized >> 6;
|
||||||
|
if (mode == 3) return null;
|
||||||
|
return mode + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? descriptorHopCount(int pathDescriptor) {
|
||||||
|
final hashSize = descriptorHashSize(pathDescriptor);
|
||||||
|
if (hashSize == null) return null;
|
||||||
|
return (pathDescriptor & 0xFF) & 0x3F;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? descriptorByteLength(int pathDescriptor) {
|
||||||
|
final hashSize = descriptorHashSize(pathDescriptor);
|
||||||
|
final hopCount = descriptorHopCount(pathDescriptor);
|
||||||
|
if (hashSize == null || hopCount == null) return null;
|
||||||
|
final byteLen = hopCount * hashSize;
|
||||||
|
return byteLen <= 64 ? byteLen : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
|
||||||
|
if (pathBytes.isEmpty) return 1;
|
||||||
|
|
||||||
|
final normalizedPreferred =
|
||||||
|
preferredHashSize != null &&
|
||||||
|
preferredHashSize >= 1 &&
|
||||||
|
preferredHashSize <= 3
|
||||||
|
? preferredHashSize
|
||||||
|
: null;
|
||||||
|
if (normalizedPreferred != null &&
|
||||||
|
pathBytes.length % normalizedPreferred == 0) {
|
||||||
|
return normalizedPreferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final candidate in const [3, 2, 1]) {
|
||||||
|
if (pathBytes.length % candidate == 0) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> splitHopHashes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||||
|
return pathBytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||||
|
hops.add(
|
||||||
|
pathBytes
|
||||||
|
.sublist(index, index + hashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<int> reverseHopBytes(
|
||||||
|
List<int> pathBytes, {
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
if (pathBytes.isEmpty) return const [];
|
||||||
|
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
|
||||||
|
return List<int>.from(pathBytes.reversed);
|
||||||
|
}
|
||||||
|
|
||||||
|
final reversed = <int>[];
|
||||||
|
for (
|
||||||
|
var index = pathBytes.length - hashSize;
|
||||||
|
index >= 0;
|
||||||
|
index -= hashSize
|
||||||
|
) {
|
||||||
|
reversed.addAll(pathBytes.sublist(index, index + hashSize));
|
||||||
|
}
|
||||||
|
return reversed;
|
||||||
|
}
|
||||||
|
|
||||||
static ResolvedNodeHash resolveHash(
|
static ResolvedNodeHash resolveHash(
|
||||||
int hash, {
|
String hashHex, {
|
||||||
required Iterable<Contact> contacts,
|
required Iterable<Contact> contacts,
|
||||||
Uint8List? ownPublicKey,
|
Uint8List? ownPublicKey,
|
||||||
String? ownName,
|
String? ownName,
|
||||||
|
double? ownLatitude,
|
||||||
|
double? ownLongitude,
|
||||||
}) {
|
}) {
|
||||||
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
? ownPublicKey.first
|
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||||
: null;
|
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
|
||||||
if (ownHash == hash) {
|
|
||||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||||
? '$ownName (you)'
|
? '$ownName (you)'
|
||||||
: 'You';
|
: 'You';
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: ownLabel,
|
label: ownLabel,
|
||||||
isOwnNode: true,
|
isOwnNode: true,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: ownLatitude,
|
||||||
|
longitude: ownLongitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final matches = contacts.where((contact) {
|
final matches = contacts.where((contact) {
|
||||||
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
|
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
if (matches.isEmpty) {
|
if (matches.isEmpty) {
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: 'Unknown',
|
label: 'Unknown',
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
@@ -98,12 +218,15 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length == 1) {
|
if (matches.length == 1) {
|
||||||
|
final location = matches.first.displayLocation;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: matches.first.displayName,
|
label: matches.first.displayName,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
matchCount: 1,
|
matchCount: 1,
|
||||||
|
latitude: location?.latitude,
|
||||||
|
longitude: location?.longitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +242,19 @@ class LogRxRouteDecoder {
|
|||||||
? '$candidateNames +$extraCount'
|
? '$candidateNames +$extraCount'
|
||||||
: candidateNames;
|
: candidateNames;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: label,
|
label: label,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
matchCount: matches.length,
|
matchCount: matches.length,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String? _bytesToHex(Uint8List? bytes) {
|
||||||
|
if (bytes == null || bytes.isEmpty) return null;
|
||||||
|
return bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
|
||||||
/// Extension for Message to provide localized delivery status
|
/// Extension for Message to provide localized delivery status
|
||||||
extension MessageLocalization on Message {
|
extension MessageLocalization on Message {
|
||||||
/// Get localized delivery status text
|
/// Get localized delivery status text
|
||||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(id);
|
||||||
|
|
||||||
// For channel messages, show echo count instead of delivery status
|
// For channel messages, show echo count instead of delivery status
|
||||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||||
@@ -26,29 +31,51 @@ extension MessageLocalization on Message {
|
|||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
if (isContactMessage) {
|
if (isContactMessage) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.pending;
|
return routeMetadata == null
|
||||||
|
? l10n.pending
|
||||||
|
: '${l10n.pending} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return routeMetadata == null
|
||||||
|
? l10n.sent
|
||||||
|
: '${l10n.sent} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (roundTripTimeMs != null) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return routeMetadata == null
|
||||||
|
? l10n.deliveredWithTime(roundTripTimeMs!)
|
||||||
|
: '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return routeMetadata == null
|
||||||
|
? l10n.delivered
|
||||||
|
: '${l10n.delivered} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.failed;
|
return routeMetadata == null
|
||||||
|
? l10n.failed
|
||||||
|
: '${l10n.failed} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
132
lib/utils/trace_node_resolver.dart
Normal file
132
lib/utils/trace_node_resolver.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class ResolvedTraceNode {
|
||||||
|
final MeshMapNode? node;
|
||||||
|
final int matchCount;
|
||||||
|
final bool usedOnlineFallback;
|
||||||
|
|
||||||
|
const ResolvedTraceNode({
|
||||||
|
required this.node,
|
||||||
|
required this.matchCount,
|
||||||
|
required this.usedOnlineFallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasMatch => node != null;
|
||||||
|
bool get isAmbiguous => matchCount > 1;
|
||||||
|
|
||||||
|
String? get matchSummary {
|
||||||
|
if (matchCount <= 1) return null;
|
||||||
|
final source = usedOnlineFallback ? 'online' : 'local';
|
||||||
|
return '$matchCount $source matches';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TraceNodeResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const TraceNodeResolver._();
|
||||||
|
|
||||||
|
static ResolvedTraceNode resolveBest({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
|
required String? prefixHex,
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
String? preferredPrefix,
|
||||||
|
}) {
|
||||||
|
if (prefixHex == null || prefixHex.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
node: null,
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allMatches = nodes
|
||||||
|
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||||
|
.toList();
|
||||||
|
if (allMatches.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
node: null,
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localMatches = allMatches
|
||||||
|
.where((node) => localPublicKeys.contains(node.publicKey))
|
||||||
|
.toList();
|
||||||
|
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
|
||||||
|
final usedOnlineFallback = localMatches.isEmpty;
|
||||||
|
|
||||||
|
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
|
||||||
|
final preferredMatches = pool
|
||||||
|
.where((node) => node.publicKey.startsWith(preferredPrefix))
|
||||||
|
.toList();
|
||||||
|
if (preferredMatches.isNotEmpty) {
|
||||||
|
pool = preferredMatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.sort((a, b) {
|
||||||
|
final distanceCompare =
|
||||||
|
_scoreNode(
|
||||||
|
a,
|
||||||
|
referenceA: referenceA,
|
||||||
|
referenceB: referenceB,
|
||||||
|
).compareTo(
|
||||||
|
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
|
||||||
|
);
|
||||||
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
|
return b.updatedAtMs.compareTo(a.updatedAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
node: pool.first,
|
||||||
|
matchCount: pool.length,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreNode(
|
||||||
|
MeshMapNode node, {
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(node.latitude, node.longitude);
|
||||||
|
if (referenceA != null && referenceB != null) {
|
||||||
|
return _distanceToSegmentMeters(point, referenceA, referenceB);
|
||||||
|
}
|
||||||
|
if (referenceA != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceA);
|
||||||
|
}
|
||||||
|
if (referenceB != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceB);
|
||||||
|
}
|
||||||
|
return double.maxFinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,20 +11,41 @@ const int _defaultLoRaCrcEnabled = 1;
|
|||||||
const int _defaultLoRaExplicitHeader = 1;
|
const int _defaultLoRaExplicitHeader = 1;
|
||||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||||
|
|
||||||
/// Identifies which Codec2 mode was used for a voice packet.
|
enum VoiceCodecKind {
|
||||||
/// Matches the modeId byte in the text/binary packet header.
|
codec2(0, 'Codec2');
|
||||||
enum VoicePacketMode {
|
|
||||||
mode700c(0, '700C'),
|
|
||||||
mode1200(1, '1200'),
|
|
||||||
mode2400(2, '2400'),
|
|
||||||
mode1300(3, '1300'),
|
|
||||||
mode1400(4, '1400'),
|
|
||||||
mode1600(5, '1600'),
|
|
||||||
mode3200(6, '3200');
|
|
||||||
|
|
||||||
const VoicePacketMode(this.id, this.label);
|
const VoiceCodecKind(this.id, this.label);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies which voice codec/mode was used for a packet.
|
||||||
|
/// Matches the modeId byte in the text/binary packet header.
|
||||||
|
enum VoicePacketMode {
|
||||||
|
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
|
||||||
|
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
|
||||||
|
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
|
||||||
|
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
|
||||||
|
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
|
||||||
|
|
||||||
|
const VoicePacketMode(
|
||||||
|
this.id,
|
||||||
|
this.label,
|
||||||
|
this.codec,
|
||||||
|
this.sampleRateHz,
|
||||||
|
this.bytesPerSecond,
|
||||||
|
this.packetDurationMs,
|
||||||
|
);
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
final VoiceCodecKind codec;
|
||||||
|
final int sampleRateHz;
|
||||||
|
final int bytesPerSecond;
|
||||||
|
final int packetDurationMs;
|
||||||
|
|
||||||
|
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
|
||||||
|
|
||||||
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||||
(m) => m.id == id,
|
(m) => m.id == id,
|
||||||
@@ -153,23 +174,21 @@ class VoicePacket {
|
|||||||
|
|
||||||
/// Estimated audio duration of this packet in milliseconds.
|
/// Estimated audio duration of this packet in milliseconds.
|
||||||
int get durationMs {
|
int get durationMs {
|
||||||
// bytesPerSecond for each mode
|
try {
|
||||||
final bps = switch (mode) {
|
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||||
VoicePacketMode.mode700c => 100,
|
if (bytesPerSecond <= 0) return 0;
|
||||||
VoicePacketMode.mode1200 => 150,
|
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
|
||||||
VoicePacketMode.mode1300 => 175,
|
} catch (_) {
|
||||||
VoicePacketMode.mode1400 => 175,
|
// Be permissive with stale or malformed persisted voice metadata.
|
||||||
VoicePacketMode.mode1600 => 200,
|
return 0;
|
||||||
VoicePacketMode.mode2400 => 300,
|
}
|
||||||
VoicePacketMode.mode3200 => 400,
|
|
||||||
};
|
|
||||||
if (bps == 0) return 0;
|
|
||||||
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
final suffix = total > 0
|
||||||
|
? ' ${mode.label} [$index/${total - 1}]'
|
||||||
|
: ' [$index]';
|
||||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,10 +263,10 @@ class VoiceEnvelope {
|
|||||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||||
VoicePacketMode.mode700c => 100,
|
VoicePacketMode.mode700c => 100,
|
||||||
VoicePacketMode.mode1200 => 150,
|
VoicePacketMode.mode1200 => 150,
|
||||||
|
VoicePacketMode.mode2400 => 300,
|
||||||
VoicePacketMode.mode1300 => 175,
|
VoicePacketMode.mode1300 => 175,
|
||||||
VoicePacketMode.mode1400 => 175,
|
VoicePacketMode.mode1400 => 175,
|
||||||
VoicePacketMode.mode1600 => 200,
|
VoicePacketMode.mode1600 => 200,
|
||||||
VoicePacketMode.mode2400 => 300,
|
|
||||||
VoicePacketMode.mode3200 => 400,
|
VoicePacketMode.mode3200 => 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,8 +438,7 @@ class VoiceFetchRequest {
|
|||||||
this.version = 3,
|
this.version = 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) =>
|
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||||
text.startsWith(_prefix);
|
|
||||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
@@ -473,9 +491,7 @@ class VoiceFetchRequest {
|
|||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m'))
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
? 'missing'
|
|
||||||
: wantToken);
|
|
||||||
|
|
||||||
if (sid == null) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -602,7 +618,11 @@ String _toBase36(int value) => value.toRadixString(36);
|
|||||||
|
|
||||||
String _encodeSessionId(String sessionIdHex) {
|
String _encodeSessionId(String sessionIdHex) {
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
throw ArgumentError.value(
|
||||||
|
sessionIdHex,
|
||||||
|
'sessionIdHex',
|
||||||
|
'Expected 8 hex chars',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final value = int.parse(sessionIdHex, radix: 16);
|
final value = int.parse(sessionIdHex, radix: 16);
|
||||||
return value.toRadixString(36);
|
return value.toRadixString(36);
|
||||||
@@ -616,10 +636,7 @@ String? _decodeSessionId(String token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||||
final sorted = indices
|
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||||
.where((v) => v >= 0 && v <= 254)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
..sort();
|
||||||
if (sorted.isEmpty) return '';
|
if (sorted.isEmpty) return '';
|
||||||
final chunks = <String>[];
|
final chunks = <String>[];
|
||||||
@@ -632,7 +649,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
chunks.add(
|
chunks.add(
|
||||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
start == prev
|
||||||
|
? _toBase36(start)
|
||||||
|
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
);
|
);
|
||||||
start = curr;
|
start = curr;
|
||||||
prev = curr;
|
prev = curr;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
final emoji = contact.roleEmoji;
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
if (emoji != null && emoji.isNotEmpty) {
|
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
|
||||||
return _buildAvatarFrame(
|
return _buildAvatarFrame(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
bool get _usesSquareShape =>
|
bool get _usesSquareShape =>
|
||||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||||
|
|
||||||
|
bool get _showsLeadingEmoji {
|
||||||
|
final emoji = contact.roleEmoji;
|
||||||
|
if (emoji == null || emoji.isEmpty) return false;
|
||||||
|
|
||||||
|
final effectiveName = (displayName ?? contact.displayName).trimLeft();
|
||||||
|
return effectiveName.startsWith(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(BuildContext context) {
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
|
||||||
return TrailColorService.getTrailColor(contact);
|
return TrailColorService.getTrailColor(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
Future<void> _loadLocalIPs() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
final Set<String> ipsSet = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
/// Validate channel name
|
/// Validate channel name
|
||||||
String? _validateName(String? value) {
|
String? _validateName(String? value) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final trimmedValue = value?.trim() ?? '';
|
||||||
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (trimmedValue.isEmpty) {
|
||||||
return l10n.channelNameRequired;
|
return l10n.channelNameRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.length > 31) {
|
if (trimmedValue.length > 31) {
|
||||||
return l10n.channelNameTooLong;
|
return l10n.channelNameTooLong;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isAscii(value)) {
|
if (!_isAscii(trimmedValue)) {
|
||||||
return l10n.invalidAsciiCharacters;
|
return l10n.invalidAsciiCharacters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isHashChannel = _nameController.text.startsWith('#');
|
final normalizedName = _nameController.text.trimLeft();
|
||||||
|
final isHashChannel = normalizedName.startsWith('#');
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(l10n.addChannel),
|
title: Text(l10n.addChannel),
|
||||||
@@ -163,8 +165,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
enabled: !_isCreating,
|
enabled: !_isCreating,
|
||||||
maxLength: 31,
|
maxLength: 31,
|
||||||
validator: _validateName,
|
validator: _validateName,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: isHashChannel
|
||||||
|
? TextInputAction.done
|
||||||
|
: TextInputAction.next,
|
||||||
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
if (isHashChannel) {
|
||||||
|
_handleCreate();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
// Channel Secret Field (only show for private channels)
|
// Channel Secret Field (only show for private channels)
|
||||||
if (!isHashChannel) ...[
|
if (!isHashChannel) ...[
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user