Compare commits

...

106 Commits

Author SHA1 Message Date
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
Janez T
05153c9ceb Clarify path_provider override 2026-03-08 09:30:30 +01:00
Janez T
cd508d5f74 Modernize device settings screen 2026-03-08 09:24:26 +01:00
Janez T
937f91e496 Preserve contact telemetry data 2026-03-08 09:23:07 +01:00
Janez T
1f826ae4c2 Set simple mode default 2026-03-08 09:19:13 +01:00
Janez T
2e15ccb3f3 Fix outdated packages 2026-03-08 09:11:30 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
Janez T
e904d53d8b Bump Flutter and iOS build versions 2026-03-07 21:13:44 +01:00
Janez T
c4aef05dcf Use flutter_map main source 2026-03-07 21:10:54 +01:00
Janez T
1c3af4f927 Upgrade flutter packages and limit 2026-03-07 20:59:01 +01:00
Janez T
fe2e08c4e4 Add faster location update channel 2026-03-07 20:38:49 +01:00
Janez T
3433eae3a6 Remove unused path_provider entry 2026-03-07 20:23:09 +01:00
155 changed files with 47117 additions and 28727 deletions

View File

@@ -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

View File

@@ -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:

3
.gitignore vendored
View File

@@ -80,10 +80,11 @@ create_feature_graphic.py
# Tool caches and local state # Tool caches and local state
.cachebro/ .cachebro/
.osgrep/ .osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config) # Claude Code local settings (permissions, personal config)
.claude/settings.local.json .claude/settings.local.json
# Dart code coverage # Dart code coverage
coverage/ coverage/
lcov.info lcov.info

13
CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# Changelog
## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
## Key PRs
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`

View File

@@ -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

View File

@@ -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

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100; CURRENT_PROJECT_VERSION = 115;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -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)
} }
} }

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>100</string> <string>115</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
@@ -102,6 +102,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string> <string>bluetooth-peripheral</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View File

@@ -5,17 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="49.579697"> <testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
</testcase> </testcase>

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,11 +20,12 @@ 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/discovery_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart'; import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@@ -39,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
} }
class _MeshCoreSarAppState extends State<MeshCoreSarApp> { class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system; AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale; Locale? _locale;
bool _isInitialized = false; bool _isInitialized = false;
bool _shouldShowPermissionDialog = false; bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp() bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override @override
void initState() { void initState() {
@@ -63,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications // Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap; NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions // Check if we need to request location permissions
await _checkLocationPermissions(); await _checkLocationPermissions();
@@ -71,10 +78,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available // Shows notification if update is available
_checkForUpdates(); _checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() { setState(() {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;
}); });
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
} }
/// Handle notification tap /// Handle notification tap
@@ -83,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload'); debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap // Handle update notification tap
if (payload.startsWith('update:')) { if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl); _launchUpdateDownload(downloadUrl);
return;
}
if (payload.startsWith('discovery:')) {
final navigator = _navigatorKey.currentState;
if (navigator == null) {
_pendingNotificationPayload = payload;
return;
}
navigator.push(
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
);
} }
// SAR and message notifications handled by their respective providers // SAR and message notifications handled by their respective providers
} }
@@ -159,7 +194,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available /// Shows notification if update is available
Future<void> _checkForUpdates() async { Future<void> _checkForUpdates() async {
// Only check for updates on Android // Only check for updates on Android
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)'); debugPrint('[UpdateChecker] Skipping update check (not Android)');
return; return;
} }
@@ -248,18 +283,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 +301,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 +310,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages, messages,
drawings, drawings,
channels, channels,
tileCache,
previous, previous,
) => ) =>
previous ?? previous ??
@@ -292,7 +321,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,
), ),
), ),
], ],
@@ -305,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) { builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context); final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp( final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
key: ValueKey<String?>( key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}', '${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
), ),
@@ -332,7 +361,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36) // Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom) // iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea( return SafeArea(
left: false, left: false,
right: false, right: false,

View File

@@ -231,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;
} }

View File

@@ -0,0 +1,39 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
final List<String>? matchPrefixes;
final bool isAutoGroup;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
this.matchPrefixes,
this.isAutoGroup = false,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
List<String>? matchPrefixes,
bool? isAutoGroup,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
);
}
}

View File

@@ -66,6 +66,11 @@ class DeviceInfo {
final int? advLat; final int? advLat;
final int? advLon; final int? advLon;
final bool? manualAddContacts; final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat, this.advLat,
this.advLon, this.advLon,
this.manualAddContacts, this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat, int? advLat,
int? advLon, int? advLon,
bool? manualAddContacts, bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat, advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon, advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts, manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,

View File

@@ -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,
);
}
} }

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs; final int? senderToReceiptMs;
final int? estimatedTransmitMs; final int? estimatedTransmitMs;
final int? postTransmitDelayMs; final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({ const MessageReceptionDetails({
required this.capturedAt, required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs, this.senderToReceiptMs,
this.estimatedTransmitMs, this.estimatedTransmitMs,
this.postTransmitDelayMs, this.postTransmitDelayMs,
this.receivedCopies = 1,
}); });
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs, 'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs, 'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs, 'postTransmitDelayMs': postTransmitDelayMs,
'receivedCopies': receivedCopies,
}; };
} }
MessageReceptionDetails copyWith({
DateTime? capturedAt,
DateTime? packetLoggedAt,
int? rssiDbm,
double? snrDb,
List<int>? pathBytes,
int? senderToReceiptMs,
int? estimatedTransmitMs,
int? postTransmitDelayMs,
int? receivedCopies,
}) {
return MessageReceptionDetails(
capturedAt: capturedAt ?? this.capturedAt,
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
rssiDbm: rssiDbm ?? this.rssiDbm,
snrDb: snrDb ?? this.snrDb,
pathBytes: pathBytes ?? this.pathBytes,
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
receivedCopies: receivedCopies ?? this.receivedCopies,
);
}
static MessageReceptionDetails mergeDuplicate({
MessageReceptionDetails? existing,
MessageReceptionDetails? incoming,
}) {
final base =
existing ??
incoming ??
MessageReceptionDetails(capturedAt: DateTime.now());
return base.copyWith(
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
snrDb: incoming?.snrDb ?? existing?.snrDb,
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
senderToReceiptMs:
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
estimatedTransmitMs:
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
postTransmitDelayMs:
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
receivedCopies:
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
);
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) { static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis']; final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) { if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?, senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?, estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?, postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
); );
} }
} }

View 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?,
);
}
}

View 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,
);
}
}

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings /// Provider for managing map drawings
class DrawingProvider with ChangeNotifier { class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings'; static const String _storageKey = 'map_drawings';
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
static const String _showSarMarkersKey = 'map_show_sar_markers';
// Drawing state // Drawing state
DrawingMode _drawingMode = DrawingMode.none; DrawingMode _drawingMode = DrawingMode.none;
@@ -20,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings // Completed drawings
final List<MapDrawing> _drawings = []; final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing // In-progress drawing
MapDrawing? _currentDrawing; MapDrawing? _currentDrawing;
@@ -51,13 +54,16 @@ class DrawingProvider with ChangeNotifier {
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints); List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint; LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none; bool get isDrawing => _drawingMode != DrawingMode.none;
bool get isInitialized => _isInitialized;
LatLng? get measurementPoint1 => _measurementPoint1; LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2; LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance; double? get measuredDistance => _measuredDistance;
/// Initialize and load saved drawings /// Initialize and load saved drawings
Future<void> initialize() async { Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings(); await _loadDrawings();
_isInitialized = true;
} }
/// Set drawing mode /// Set drawing mode
@@ -77,15 +83,30 @@ class DrawingProvider with ChangeNotifier {
} }
/// Toggle visibility of received drawings /// Toggle visibility of received drawings
void toggleReceivedDrawings() { Future<void> toggleReceivedDrawings() async {
_showReceivedDrawings = !_showReceivedDrawings; _showReceivedDrawings = !_showReceivedDrawings;
notifyListeners(); notifyListeners();
await _savePreferences();
} }
/// Toggle visibility of SAR markers /// Toggle visibility of SAR markers
void toggleSarMarkers() { Future<void> toggleSarMarkers() async {
_showSarMarkers = !_showSarMarkers; _showSarMarkers = !_showSarMarkers;
notifyListeners(); notifyListeners();
await _savePreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
} }
/// Start drawing a line /// Start drawing a line

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
/// Message delivery tracking helper /// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup /// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {}; final Map<int, DateTime> _ackTagTimestamps = {};
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
Completer<void>? _slotFreedCompleter;
/// Track a pending message ID before sending /// Track a pending message ID before sending
/// ///
/// This is called BEFORE sending the message. When RESP_CODE_SENT /// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId); _messageIdToAckTag.remove(messageId);
} }
_ackTagTimestamps.remove(ackCode); _ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
} }
/// Remove ACK tag mapping by message ID /// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) { if (ackTag != null) {
_ackTagToMessageId.remove(ackTag); _ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag); _ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
} }
_pendingMessageIds.remove(messageId); _pendingMessageIds.remove(messageId);
final emptyKeys = <String>[]; final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear(); _ackTagToMessageId.clear();
_messageIdToAckTag.clear(); _messageIdToAckTag.clear();
_ackTagTimestamps.clear(); _ackTagTimestamps.clear();
_notifySlotFreed();
} }
/// Get count of pending ACK tags /// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8) /// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7; bool get shouldRateLimit => pendingCount >= 7;
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
///
/// Returns immediately if not at the rate limit.
Future<void> waitForSlot({
Duration timeout = const Duration(milliseconds: 500),
}) async {
if (!shouldRateLimit) return;
_slotFreedCompleter ??= Completer<void>();
await _slotFreedCompleter!.future.timeout(
timeout,
onTimeout: () {},
);
}
void _notifySlotFreed() {
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
_slotFreedCompleter!.complete();
}
_slotFreedCompleter = null;
}
/// Get oldest pending ACK timestamp (for debugging) /// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp { DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null; if (_ackTagTimestamps.isEmpty) return null;

View File

@@ -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

View File

@@ -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++;

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
import '../models/map_drawing.dart'; import '../models/map_drawing.dart';
class MapProvider with ChangeNotifier { class MapProvider with ChangeNotifier {
MapProvider() {
unawaited(_loadInitialState());
}
LatLng? _targetLocation; LatLng? _targetLocation;
double? _targetZoom; double? _targetZoom;
bool _shouldAnimate = false; bool _shouldAnimate = false;
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
// Contact trail toggles // Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails bool _showAllContactTrails = true; // Default to showing all contact trails
bool _hideRepeatersOnMap = false;
// Imported trail (from GPX) // Imported trail (from GPX)
LocationTrail? _importedTrail; LocationTrail? _importedTrail;
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
// Contact trail getters // Contact trail getters
bool get showAllContactTrails => _showAllContactTrails; bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters // Imported trail getters
LocationTrail? get importedTrail => _importedTrail; LocationTrail? get importedTrail => _importedTrail;
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _loadInitialState() async {
await Future.wait([loadOverlayState(), loadTrailSettings()]);
await loadRepeaterVisibilitySettings();
}
/// Save overlay state to SharedPreferences /// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async { Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails); await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
} }
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
}
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
notifyListeners();
}
/// Set imported trail (from GPX import) /// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) { void setImportedTrail(LocationTrail trail) {
_importedTrail = trail; _importedTrail = trail;

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier { class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans'; static const String _fieldSpanKey = 'sensor_field_spans';
@@ -26,6 +27,7 @@ class SensorsProvider with ChangeNotifier {
final List<String> _watchedSensorKeys = <String>[]; final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates = final Map<String, SensorRefreshState> _refreshStates =
<String, SensorRefreshState>{}; <String, SensorRefreshState>{};
final Map<String, DateTime> _refreshStateUpdatedAt = <String, DateTime>{};
final Map<String, Set<String>> _visibleFieldsBySensor = final Map<String, Set<String>> _visibleFieldsBySensor =
<String, Set<String>>{}; <String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor = final Map<String, Map<String, int>> _fieldSpansBySensor =
@@ -189,6 +191,7 @@ class SensorsProvider with ChangeNotifier {
Future<void> removeSensor(String publicKeyHex) async { Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex); _watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex); _refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex); _visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex); _fieldSpansBySensor.remove(publicKeyHex);
await _persistWatchedSensors(); await _persistWatchedSensors();
@@ -220,35 +223,78 @@ class SensorsProvider with ChangeNotifier {
try { try {
for (final key in _watchedSensorKeys) { for (final key in _watchedSensorKeys) {
Contact? contact; await refreshSensor(
for (final entry in contactsProvider.contacts) { publicKeyHex: key,
if (entry.publicKeyHex == key) { contactsProvider: contactsProvider,
contact = entry; connectionProvider: connectionProvider,
break;
}
}
if (contact == null) {
_refreshStates[key] = SensorRefreshState.unavailable;
notifyListeners();
continue;
}
_refreshStates[key] = SensorRefreshState.refreshing;
notifyListeners();
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
); );
_refreshStates[key] = result.success
? SensorRefreshState.success
: SensorRefreshState.timeout;
notifyListeners();
} }
} finally { } finally {
_isRefreshingAll = false; _isRefreshingAll = false;
notifyListeners(); notifyListeners();
} }
} }
Future<void> refreshSensor({
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
return;
}
_setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
);
_setRefreshState(
publicKeyHex,
result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
);
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];
for (final entry in _refreshStates.entries) {
if (entry.value != SensorRefreshState.success) {
continue;
}
final updatedAt = _refreshStateUpdatedAt[entry.key];
if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
keysToClear.add(entry.key);
}
}
if (keysToClear.isEmpty) {
return;
}
for (final key in keysToClear) {
_refreshStates.remove(key);
_refreshStateUpdatedAt.remove(key);
}
notifyListeners();
}
void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
_refreshStates[publicKeyHex] = state;
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
} }

View File

@@ -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

View File

@@ -0,0 +1,660 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'dart:typed_data';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
}
class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _repeaterAdvertType = 2;
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
bool _isResolvingAll = false;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
}
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
}
Future<void> _clearAllDiscoveries() async {
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
if (pendingCount == 0) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Clear discoveries'),
content: Text(
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Clear all'),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
await context.read<ContactsProvider>().clearPendingAdverts();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cleared pending discoveries.')),
);
}
Future<void> _discoverNodeType(int advertType) async {
if (_runningDiscoveryTypes.contains(advertType)) return;
setState(() {
_runningDiscoveryTypes.add(advertType);
});
try {
await context.read<ConnectionProvider>().discoverNodeType(
advertType: advertType,
);
if (!mounted) return;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_ => 'Discovery sent',
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(label)));
} finally {
if (mounted) {
setState(() {
_runningDiscoveryTypes.remove(advertType);
});
}
}
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
await connectionProvider.getContact(advert.publicKey);
if (connectionProvider.error == 'Not found') {
connectionProvider.clearError();
final fallbackContact = _contactFromPendingAdvert(advert);
await connectionProvider.addOrUpdateContact(fallbackContact);
contactsProvider.addOrUpdateContact(
fallbackContact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
}
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
await connectionProvider.requestTelemetry(advert.publicKey);
}
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
if (_isResolvingAll || adverts.isEmpty) return;
setState(() {
_isResolvingAll = true;
});
try {
for (final advert in adverts) {
if (!mounted) break;
await _resolveAdvert(advert);
}
} finally {
if (mounted) {
setState(() {
_isResolvingAll = false;
});
}
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
Contact _contactFromPendingAdvert(PendingAdvert advert) {
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
final advName = advert.advName?.trim().isNotEmpty == true
? advert.advName!.trim()
: advert.shortDisplayKey;
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.fromValue(advert.typeValue ?? 0),
flags: advert.flags ?? 0,
outPathLen: advert.signedEncodedPathLen ?? -1,
outPath: advert.paddedPathBytes == null
? Uint8List(64)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
String _displayNameForAdvert(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final advertisedName = advert.advName?.trim();
if (advertisedName != null && advertisedName.isNotEmpty) {
return advertisedName;
}
Contact? existingMatch;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) {
existingMatch = contact;
break;
}
}
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
return existingMatch.displayName;
}
MeshMapNode? cachedMatch;
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
cachedMatch = node;
break;
}
}
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
return cachedMatch.name;
}
return advert.shortDisplayKey;
}
IconData _iconForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => Icons.router_outlined,
_sensorAdvertType => Icons.sensors_outlined,
_ => Icons.campaign_outlined,
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
1 => 'Chat',
_ => null,
};
}
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
_ => null,
};
}
}
return null;
}
Widget _buildAdvertTitle(
BuildContext context, {
required String displayName,
String? subtitle,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: const Text('Discovery'),
actions: [
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected;
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
return PopupMenuButton<_DiscoveryMenuAction>(
tooltip: 'Discovery tools',
onSelected: _handleMenuAction,
itemBuilder: (context) => [
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.repeaters,
enabled: isConnected && !repeatersBusy,
child: Row(
children: [
repeatersBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
const SizedBox(width: 12),
const Text('Discover repeaters'),
],
),
),
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.sensors,
enabled: isConnected && !sensorsBusy,
child: Row(
children: [
sensorsBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
const SizedBox(width: 12),
const Text('Discover sensors'),
],
),
),
],
);
},
),
],
),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
'Pending discoveries (${pendingAdverts.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.download_for_offline_outlined,
),
label: const Text('Resolve all'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: const Icon(Icons.clear_all_rounded),
label: const Text('Clear all'),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (pendingAdverts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Unknown adverts will appear here until you choose to resolve them.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex,
);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(
advert,
cachedNodes,
);
final downMetric = SignalMetric.fromValues(
rssiDbm: advert.rxRssiDbm,
snrDb: advert.rxSnr,
);
final upMetric = SignalMetric.fromValues(
rssiDbm: advert.repeaterLastRssi,
snrDb: advert.repeaterLastSnr,
);
final detailLines = <String>[
'${l10n.publicKey}: ${advert.shortDisplayKey}',
];
final summaryParts = <String>[];
final battery = advert.repeaterBatteryPercent;
if (battery != null) {
summaryParts.add('Battery ${battery.round()}%');
}
if (advert.repeaterQueueLen != null) {
summaryParts.add('Queue ${advert.repeaterQueueLen}');
}
if (summaryParts.isNotEmpty) {
detailLines.add(summaryParts.join(''));
}
detailLines.add(
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
child: Icon(_iconForAdvert(advert)),
),
const SizedBox(width: 12),
Expanded(
child: _buildAdvertTitle(
context,
displayName: displayName,
subtitle: typeLabel,
),
),
if (downMetric != null || upMetric != null) ...[
const SizedBox(width: 12),
_buildSignalSummary(
context,
downMetric: downMetric,
upMetric: upMetric,
),
],
const SizedBox(width: 8),
isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(
Icons.person_add_alt_1,
),
tooltip: 'Resolve contact',
onPressed: isConnected
? () => _resolveAdvert(advert)
: null,
),
],
),
const SizedBox(height: 10),
Text(
detailLines.join('\n'),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}),
],
);
},
),
),
);
}
Widget _buildSignalSummary(
BuildContext context, {
SignalMetric? downMetric,
SignalMetric? upMetric,
}) {
if (downMetric == null && upMetric == null) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (downMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.south_west_rounded,
metric: downMetric,
),
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
if (upMetric != null)
_buildDirectionalSignal(
context,
icon: Icons.north_east_rounded,
metric: upMetric,
),
],
);
}
Widget _buildDirectionalSignal(
BuildContext context, {
required IconData icon,
required SignalMetric metric,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 3),
_buildMiniSignalBars(context, metric),
const SizedBox(width: 4),
Text(
metric.valueLabel,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
);
}
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 2),
Container(
width: 3,
height: 6.0 + (index * 4),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
);
}
}

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

View File

@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget { class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final bool isActive;
const MessagesTab({super.key, this.onNavigateToMap}); const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
@override @override
State<MessagesTab> createState() => _MessagesTabState(); State<MessagesTab> createState() => _MessagesTabState();
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156; static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127; static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148; static const double _composerOverlayHeight = 148;
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
int _messageByteCount = 0; int _messageByteCount = 0;
String? _highlightedMessageId; String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state // Message destination state
String _destinationType = String _destinationType =
@@ -71,7 +79,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const int _maxVoicePackets = 10; static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -85,18 +93,17 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textController.addListener(_updateCharacterCount); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -117,29 +124,52 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void dispose() { void dispose() {
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel(); _voiceStreamSub?.cancel();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@override
void didUpdateWidget(covariant MessagesTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
}
}
void _checkForNavigationRequest() { void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId; final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
if (targetMessageId != null) { if (targetMessageId != null) {
_scrollToMessage(targetMessageId); _scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation(); messagesProvider.clearMessageNavigation();
} }
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
}
} }
void _scrollToMessage(String messageId) { void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider); final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId); final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) { if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list // Calculate position - accounting for reverse list
@@ -170,12 +200,130 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _handleComposerChanged() {
_updateCharacterCount();
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() { void _updateCharacterCount() {
setState(() { setState(() {
_messageByteCount = utf8.encode(_textController.text).length; _messageByteCount = utf8.encode(_textController.text).length;
}); });
} }
void _handleFocusChanged() {
if (!_focusNode.hasFocus) {
_clearMentionSuggestions();
return;
}
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null;
}
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
return contacts.take(8).toList(growable: false);
}
int get _maxMessageBytes => int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel _destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes ? _maxChannelMessageBytes
@@ -258,6 +406,7 @@ class _MessagesTabState extends State<MessagesTab> {
/// Show recipient selector bottom sheet /// Show recipient selector bottom sheet
void _showRecipientSelector() { void _showRecipientSelector() {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type // Filter contacts by type
final contacts = contactsProvider.contacts final contacts = contactsProvider.contacts
@@ -278,6 +427,13 @@ class _MessagesTabState extends State<MessagesTab> {
contacts: contacts, contacts: contacts,
rooms: rooms, rooms: rooms,
channels: channels, channels: channels,
unreadCount: messagesProvider.unreadCount,
unreadCountsByPublicKey: {
for (final contact in [...contacts, ...rooms, ...channels])
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
contact,
),
},
currentDestinationType: _destinationType, currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex, currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected, onSelect: _onRecipientSelected,
@@ -292,6 +448,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_markCurrentDestinationAsRead();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Save to preferences // Save to preferences
@@ -304,9 +462,139 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return; if (!mounted) return;
} }
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = replacementRange ?? value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_suppressMentionTrigger = true;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit();
}
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String? senderDisplayName;
String destinationType;
Contact? recipient;
if (message.isChannelMessage) {
final channelIdx = message.channelIdx ?? 0;
destinationType = MessageDestinationPreferences.destinationTypeChannel;
if (channelIdx != 0) {
recipient = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == channelIdx;
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
}
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final roomRecipient = message.recipientPublicKey == null
? null
: contactsProvider.findContactByKey(message.recipientPublicKey!);
if (roomRecipient?.isRoom == true) {
destinationType = MessageDestinationPreferences.destinationTypeRoom;
recipient = roomRecipient;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus();
}
/// Get icon for current destination type /// Get icon for current destination type
IconData _getDestinationIcon() { IconData _getDestinationIcon() {
if (_destinationType == if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return Icons.all_inbox;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) { MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public; return Icons.public;
} else if (_destinationType == } else if (_destinationType ==
@@ -318,6 +606,9 @@ class _MessagesTabState extends State<MessagesTab> {
} }
String _getDestinationLabel() { String _getDestinationLabel() {
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return AppLocalizations.of(context)!.showAll;
}
if (_destinationType == if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel && MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) { _selectedRecipient != null) {
@@ -343,6 +634,15 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
return;
}
_textController.clear();
_focusNode.unfocus();
try { try {
// Check destination type and send accordingly // Check destination type and send accordingly
if (_destinationType == if (_destinationType ==
@@ -373,12 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
await _sendToChannel(text, connectionProvider, messagesProvider, 0); await _sendToChannel(text, connectionProvider, messagesProvider, 0);
} }
_textController.clear(); _markCurrentDestinationAsRead();
_focusNode.unfocus();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
if (_textController.text.isEmpty) {
_textController.text = text;
_textController.selection = TextSelection.collapsed(
offset: _textController.text.length,
);
_focusNode.requestFocus();
}
ToastLogger.error(context, 'Failed to send: $e'); ToastLogger.error(context, 'Failed to send: $e');
} }
} }
@@ -692,7 +998,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately. // Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate(); final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -731,11 +1037,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate, _selectedVoiceBitrate,
); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -744,9 +1051,13 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -757,7 +1068,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -774,6 +1085,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -788,9 +1105,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -979,6 +1301,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1008,16 +1337,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0; var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
}
} }
return peak;
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {
@@ -1107,6 +1448,15 @@ class _MessagesTabState extends State<MessagesTab> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile( ListTile(
leading: const Icon(Icons.add_location_alt), leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker), title: Text(AppLocalizations.of(context)!.sendSarMarker),
@@ -1174,6 +1524,116 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider);
final searchController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
final query = searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? scopedMessages
: scopedMessages.where((message) {
return message.text.toLowerCase().contains(query) ||
(message.senderName?.toLowerCase().contains(query) ??
false);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Search in current filter',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
setModalState(() {});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
onChanged: (_) => setModalState(() {}),
),
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
query.isEmpty
? 'No messages in this filter'
: 'No matches in this filter',
),
)
: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final message = matches[index];
final title =
message.senderName?.trim().isNotEmpty ==
true
? message.senderName!
: message.isSentMessage
? 'You'
: _getDestinationLabel();
return ListTile(
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
message.text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Navigator.pop(sheetContext);
_scrollToMessage(message.id);
},
);
},
),
),
],
),
),
),
);
},
);
},
).whenComplete(searchController.dispose);
}
Widget _buildDestinationAvatar(BuildContext context) { Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient; final recipient = _selectedRecipient;
if (recipient != null) { if (recipient != null) {
@@ -1432,28 +1892,84 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _markCurrentDestinationAsRead() {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
if (!widget.isActive ||
_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
if (unreadCount <= 0) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final nextKey = '$channelIdx:$unreadCount';
if (_pendingChannelReadKey == nextKey &&
_channelReadTimer?.isActive == true) {
return;
}
_channelReadTimer?.cancel();
_pendingChannelReadKey = nextKey;
_channelReadTimer = Timer(_channelAutoReadDelay, () {
if (!mounted || !widget.isActive) {
return;
}
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return;
}
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (currentChannelIdx != channelIdx) {
return;
}
final latestProvider = context.read<MessagesProvider>();
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
return;
}
_markCurrentDestinationAsRead();
});
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);
// 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) { filteredMessages = allMessages
// Public channel view keeps showing all messages (current app behavior). .where(
filteredMessages = allMessages; (message) =>
} else { message.isChannelMessage &&
filteredMessages = allMessages (message.channelIdx ?? 0) == selectedChannelIdx,
.where((message) => message.channelIdx == selectedChannelIdx) )
.toList(); .toList();
}
} }
// If a contact or room is selected, filter by recipient/sender prefixes. // If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType == else if ((_destinationType ==
@@ -1462,6 +1978,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeRoom) && MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) { _selectedRecipient != null) {
filteredMessages = allMessages.where((message) { filteredMessages = allMessages.where((message) {
if (!message.isContactMessage) {
return false;
}
// Include messages sent TO this recipient // Include messages sent TO this recipient
if (message.recipientPublicKey != null && if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 && message.recipientPublicKey!.length >= 6 &&
@@ -1492,14 +2012,9 @@ class _MessagesTabState extends State<MessagesTab> {
filteredMessages = allMessages; filteredMessages = allMessages;
} }
// In simple mode, filter out system messages (toast logs) return filteredMessages
if (isSimpleMode) { .where((message) => !message.isSystemMessage)
filteredMessages = filteredMessages .toList();
.where((message) => !message.isSystemMessage)
.toList();
}
return filteredMessages;
} }
void _handleMessageTap(Message message) { void _handleMessageTap(Message message) {
@@ -1528,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider); _syncChannelAutoReadTimer(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom; final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -1547,6 +2065,7 @@ class _MessagesTabState extends State<MessagesTab> {
onRefresh: _handleRefresh, onRefresh: _handleRefresh,
onNavigateToMap: widget.onNavigateToMap, onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap, onMessageTap: _handleMessageTap,
onReplyToMessage: _replyToMessage,
), ),
), ),
Positioned( Positioned(
@@ -1565,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions, onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector, onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,

View File

@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget { class PacketLogScreen extends StatefulWidget {
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue; final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
), ),
), ),
], ],
if (isRx && decodedRoute != null) ...[ if (isRx) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_RouteSection( _DecodedRouteSection(log: log),
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@@ -755,16 +733,96 @@ class _PacketLogCard extends StatelessWidget {
} }
} }
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: snapshot.data?.first as int?,
);
if (decodedRoute == null) {
return const SizedBox.shrink();
}
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes
.map(
(hashHex) => _resolveHashWithFallback(
hashHex,
contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
return _RouteSection(route: decodedRoute, path: resolvedPath);
},
);
}
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
}
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route; final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path; final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({ const _RouteSection({required this.route, required this.path});
required this.route,
required this.path,
required this.originalSender,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -802,14 +860,14 @@ class _RouteSection extends StatelessWidget {
_FactCard( _FactCard(
icon: Icons.hub, icon: Icons.hub,
label: 'Hops', label: 'Hops',
value: '${route.pathHashes.length}', value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
), ),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View 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;
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -10,9 +12,53 @@ import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart'; import '../providers/sensors_provider.dart';
import '../utils/location_formats.dart'; import '../utils/location_formats.dart';
class SensorsTab extends StatelessWidget { class SensorsTab extends StatefulWidget {
const SensorsTab({super.key}); const SensorsTab({super.key});
@override
State<SensorsTab> createState() => _SensorsTabState();
}
class _SensorsTabState extends State<SensorsTab> {
Timer? _minuteTicker;
@override
void initState() {
super.initState();
_scheduleMinuteTicker();
}
@override
void dispose() {
_minuteTicker?.cancel();
super.dispose();
}
void _scheduleMinuteTicker() {
_minuteTicker?.cancel();
final now = DateTime.now();
final nextMinute = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute + 1,
);
final delay = nextMinute.difference(now);
_minuteTicker = Timer(delay, () {
if (!mounted) return;
context.read<SensorsProvider>().clearExpiredRefreshStates();
setState(() {});
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
if (!mounted) return;
context.read<SensorsProvider>().clearExpiredRefreshStates();
setState(() {});
});
});
}
Future<void> _showAddSensorSheet(BuildContext context) async { Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -209,6 +255,11 @@ class SensorsTab extends StatelessWidget {
}, },
onCustomize: () => onCustomize: () =>
_showMetricSelector(context, key, contact), _showMetricSelector(context, key, contact),
onRefresh: () => sensorsProvider.refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
),
); );
}), }),
], ],
@@ -302,6 +353,7 @@ class _SensorCard extends StatelessWidget {
final Set<String> visibleFields; final Set<String> visibleFields;
final Map<String, int> fieldSpans; final Map<String, int> fieldSpans;
final Future<void> Function() onRemove; final Future<void> Function() onRemove;
final Future<void> Function() onRefresh;
final VoidCallback onCustomize; final VoidCallback onCustomize;
const _SensorCard({ const _SensorCard({
@@ -310,6 +362,7 @@ class _SensorCard extends StatelessWidget {
required this.visibleFields, required this.visibleFields,
required this.fieldSpans, required this.fieldSpans,
required this.onRemove, required this.onRemove,
required this.onRefresh,
required this.onCustomize, required this.onCustomize,
}); });
@@ -318,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!);
@@ -325,16 +379,23 @@ 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),
), ),
], ],
), ),
@@ -404,18 +465,24 @@ class _SensorCard extends StatelessWidget {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
onSelected: (value) async { onSelected: (value) async {
if (value == 'remove') { if (value == 'refresh') {
await onRefresh();
} else if (value == 'remove') {
await onRemove(); await onRemove();
} else if (value == 'customize') { } else if (value == 'customize') {
onCustomize(); onCustomize();
} }
}, },
itemBuilder: (context) => const [ itemBuilder: (context) => [
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'refresh',
child: Text(l10n.refresh),
),
const PopupMenuItem<String>(
value: 'customize', value: 'customize',
child: Text('Customize fields'), child: Text('Customize fields'),
), ),
PopupMenuItem<String>( const PopupMenuItem<String>(
value: 'remove', value: 'remove',
child: Text('Remove'), child: Text('Remove'),
), ),
@@ -429,7 +496,9 @@ class _SensorCard extends StatelessWidget {
'This node is no longer available in the contact list.', 'This node is no longer available in the contact list.',
) )
else if (telemetry == null) else if (telemetry == null)
const Text('No telemetry received yet. Pull down to fetch it.') const Text(
'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.',
)
else if (metrics.isEmpty) else if (metrics.isEmpty)
const Text( const Text(
'All fields are hidden. Use Visible fields to choose what to show.', 'All fields are hidden. Use Visible fields to choose what to show.',
@@ -573,7 +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';
@@ -673,49 +742,33 @@ class _MetricTile extends StatelessWidget {
final location = data.mapLocation; final location = data.mapLocation;
if (location == null) return; if (location == null) return;
await showDialog<void>( await Navigator.of(context).push(
context: context, MaterialPageRoute<void>(
builder: (dialogContext) { builder: (pageContext) {
return Dialog( return Scaffold(
insetPadding: const EdgeInsets.all(16), appBar: AppBar(
clipBehavior: Clip.antiAlias, title: Column(
child: SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
height: 420, children: [
child: Column( Text(data.label),
Text(
data.value,
style: Theme.of(pageContext).textTheme.bodySmall,
),
],
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( if (data.secondaryValue != null)
padding: const EdgeInsets.fromLTRB(16, 14, 8, 8), Padding(
child: Row( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
children: [ child: Text(
Expanded( data.secondaryValue!,
child: Column( style: Theme.of(pageContext).textTheme.bodyMedium,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [
Text(
data.label,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
data.value,
style: Theme.of(context).textTheme.bodyMedium,
),
if (data.secondaryValue != null)
Text(
data.secondaryValue!,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
IconButton(
onPressed: () => Navigator.of(dialogContext).pop(),
icon: const Icon(Icons.close),
),
],
), ),
),
Expanded( Expanded(
child: flutter_map.FlutterMap( child: flutter_map.FlutterMap(
options: flutter_map.MapOptions( options: flutter_map.MapOptions(
@@ -748,9 +801,10 @@ class _MetricTile extends StatelessWidget {
), ),
], ],
), ),
), );
); },
}, fullscreenDialog: true,
),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -13,14 +14,18 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart'; import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
@@ -67,6 +72,20 @@ 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 _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@@ -78,9 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings();
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
} }
@override @override
@@ -110,12 +134,89 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
} }
Future<void> _loadDeveloperMode() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
Future<void> _loadNotificationPreferences() async {
final service = NotificationService();
await service.initialize();
if (!mounted) return;
setState(() {
_messageNotificationsEnabled = service.messageNotificationsEnabled;
_sarNotificationsEnabled = service.sarNotificationsEnabled;
_updateNotificationsEnabled = service.updateNotificationsEnabled;
_muteForegroundNotifications = service.muteForegroundNotifications;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = false;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
return;
}
final nextTapCount = _versionTapCount + 1;
if (nextTapCount >= 3) {
await DeveloperModeService.setEnabled(true);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = true;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
return;
}
if (!mounted) return;
setState(() {
_versionTapCount = nextTapCount;
});
}
Future<void> _saveRxTxPreference(bool value) async { Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool('show_rx_tx_indicators', value);
} }
Future<void> _loadVoiceBitratePreference() async { Future<void> _loadMapPreferences() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
});
}
Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate(); final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -169,6 +270,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;
@@ -318,7 +521,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(
@@ -677,6 +880,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async { Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>( final selected = await showDialog<int>(
context: context, context: context,
@@ -756,21 +1019,74 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Notifications'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.chat_bubble_outline),
title: const Text('Message notifications'),
subtitle: const Text(
'Notify for incoming direct and channel messages',
),
value: _messageNotificationsEnabled,
onChanged: (value) async {
setState(() {
_messageNotificationsEnabled = value;
});
await NotificationService().setMessageNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.warning_amber_outlined),
title: const Text('SAR alerts'),
subtitle: const Text(
'Notify for incoming SAR markers such as found person or fire',
),
value: _sarNotificationsEnabled,
onChanged: (value) async {
setState(() {
_sarNotificationsEnabled = value;
});
await NotificationService().setSarNotificationsEnabled(value);
},
),
SwitchListTile(
secondary: const Icon(Icons.system_update),
title: const Text('Update notifications'),
subtitle: const Text(
'Notify when a newer app version is available',
),
value: _updateNotificationsEnabled,
onChanged: (value) async {
setState(() {
_updateNotificationsEnabled = value;
});
await NotificationService().setUpdateNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: const Text('Mute while app is open'),
subtitle: const Text(
'Do not show local notifications while the app is in the foreground',
),
value: _muteForegroundNotifications,
onChanged: (value) async {
setState(() {
_muteForegroundNotifications = value;
});
await NotificationService().setMuteForegroundNotifications(
value,
);
},
),
]),
_buildSectionHeader('Navigation'), _buildSectionHeader('Navigation'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.visibility_off),
title: Text(AppLocalizations.of(context)!.simpleMode),
subtitle: Text(
AppLocalizations.of(context)!.simpleModeDescription,
),
value: appProvider.isSimpleMode,
onChanged: (value) async {
await appProvider.toggleSimpleMode(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined), secondary: const Icon(Icons.map_outlined),
@@ -814,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'), title: const Text('Route path byte size'),
@@ -836,6 +1139,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: _showRouteHashSizeDialog, onTap: _showRouteHashSizeDialog,
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.swap_horiz),
title: const Text('Auto route rotation'),
subtitle: const Text(
'Rotate between best known direct paths and flood mode for room/contact sends',
),
value: appProvider.autoRouteRotationEnabled,
onChanged: (value) async {
await appProvider.toggleAutoRouteRotationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Nearest repeater fallback'),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
value: appProvider.nearestRelayFallbackEnabled,
onChanged: (value) async {
await appProvider.toggleNearestRelayFallbackEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Clear path on max retry'),
subtitle: const Text(
'Clear the route only after all retries and final router fallback fail',
),
value: appProvider.clearPathOnMaxRetry,
onChanged: (value) async {
await appProvider.toggleClearPathOnMaxRetry(value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red), leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text( title: const Text(
@@ -845,8 +1187,147 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Delete all stored message history'), subtitle: const Text('Delete all stored message history'),
onTap: _clearMessages, onTap: _clearMessages,
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListTile(
leading: const Icon(Icons.format_size),
title: const Text('Message font size'),
subtitle: Text(
'${(appProvider.messageFontScale * 100).round()}% of default',
),
trailing: SizedBox(
width: 150,
child: Slider(
value: appProvider.messageFontScale,
min: 0.85,
max: 1.4,
divisions: 11,
label: '${(appProvider.messageFontScale * 100).round()}%',
onChanged: (value) {
appProvider.setMessageFontScale(value);
},
),
),
),
),
]), ]),
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
leading: Icon(
Icons.delete_sweep,
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
title: Text(
'Clear online trace database',
style: TextStyle(
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
),
subtitle: const Text(
'Remove the 24-hour cached fallback used when local route matches are incomplete',
),
enabled: !_isClearingOnlineTraceCache,
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: _isClearingOnlineTraceCache
? null
: _clearOnlineTraceCache,
),
]),
_buildSectionHeader('Map'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.explore),
title: const Text('Rotate map with heading'),
subtitle: const Text(
'Rotate the map based on your compass or movement heading',
),
value: _rotateMapWithHeading,
onChanged: (value) async {
setState(() {
_rotateMapWithHeading = value;
});
await _saveMapPreference('map_rotate_with_heading', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('Show map debug info'),
subtitle: const Text(
'Display extra map diagnostics and internal state overlays',
),
value: _showMapDebugInfo,
onChanged: (value) async {
setState(() {
_showMapDebugInfo = value;
});
await _saveMapPreference('map_show_debug_info', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.fullscreen),
title: const Text('Open map in fullscreen'),
subtitle: const Text(
'Start the map tab in fullscreen mode by default',
),
value: _openMapInFullscreen,
onChanged: (value) async {
setState(() {
_openMapInFullscreen = value;
});
await _saveMapPreference('map_fullscreen', value);
},
),
Consumer<DrawingProvider>(
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'),
subtitle: const Text('Display SAR markers on the main map'),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.timeline),
title: const Text('Show all contact trails'),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
value: mapProvider.showAllContactTrails,
onChanged: (value) async {
await mapProvider.toggleAllContactTrails();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Hide repeaters on map'),
subtitle: const Text(
'Hide repeater contacts from the main map view',
),
value: mapProvider.hideRepeatersOnMap,
onChanged: (value) async {
await mapProvider.setHideRepeatersOnMap(value);
},
),
),
]),
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -856,6 +1337,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
@@ -902,6 +1388,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),
@@ -1013,11 +1536,38 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Network Sharing'), if (!kIsWeb) ...[
const ConnectionModeSelector(), _buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: const Text('Fast private GPS updates'),
subtitle: const Text(
'Use private zero-hop updates while moving significantly or while actively using map/messages.',
),
value: _fastLocationUpdatesEnabled,
onChanged: _setFastLocationUpdatesEnabled,
),
ListTile(
leading: const Icon(Icons.straighten),
title: const Text('Movement threshold'),
subtitle: Text(
'${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m',
),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationMovementThreshold,
),
ListTile(
leading: const Icon(Icons.timer),
title: const Text('Active-use update interval'),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence,
),
ListTile( ListTile(
leading: const Icon(Icons.location_on), leading: const Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission), title: Text(AppLocalizations.of(context)!.locationPermission),
@@ -1079,6 +1629,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})' ? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...', : 'Loading...',
), ),
onTap: _handleVersionTap,
), ),
ListTile( ListTile(
leading: const Icon(Icons.badge), leading: const Icon(Icons.badge),
@@ -1094,7 +1645,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(), onTap: () => _showAboutDialog(),
), ),
]), ]),
if (Platform.isAndroid) if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon( child: FilledButton.icon(
@@ -1393,6 +1944,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;
@@ -1405,6 +1959,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;
@@ -1412,7 +1969,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(
@@ -1447,7 +2004,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),
@@ -1494,6 +2051,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(
@@ -1506,7 +2088,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,
), ),
], ],

View File

@@ -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,
),
),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _skipDeviceSetupStep,
child: Text(l10n.wizardSkipForNow),
),
),
],
),
),
const SizedBox(height: 12),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _deviceNameController,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: l10n.wizardDeviceNameLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardDeviceNameHelp,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<_RadioPreset>(
initialValue: _selectedPreset,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.wizardConfigRegionLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardConfigRegionHelp,
),
items: _radioPresets
.map(
(preset) => DropdownMenuItem<_RadioPreset>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (preset) {
if (preset == null) return;
setState(() {
_selectedPreset = preset;
});
},
),
],
),
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPreset.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
_selectedPreset.summary,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
...[
_FeatureItem(
icon: Icons.rule,
text: l10n.wizardPresetNote1,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardPresetNote2,
),
_FeatureItem(
icon: Icons.public,
text: l10n.wizardPresetNote3,
),
].map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: _buildFeatureRow(
context,
colorScheme,
feature,
iconSize: 20,
gap: 12,
),
),
),
],
),
),
],
),
),
), ),
_FeatureItem( ),
icon: Icons.settings,
text: l10n.wizardSimpleModeFeature2,
),
],
colorScheme: colorScheme,
); );
} }
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,76 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
List<_FeatureItem>? features, List<_FeatureItem>? features,
required ColorScheme colorScheme, required ColorScheme colorScheme,
}) { }) {
return SingleChildScrollView( final l10n = AppLocalizations.of(context)!;
padding: const EdgeInsets.all(24.0), return LayoutBuilder(
child: Column( builder: (context, constraints) => Center(
mainAxisAlignment: MainAxisAlignment.center, child: ConstrainedBox(
children: [ constraints: BoxConstraints(
const SizedBox(height: 20), maxWidth: 560,
// Icon maxHeight: constraints.maxHeight,
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 80,
color: iconColor,
),
), ),
const SizedBox(height: 32), child: Padding(
// Title padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
Text( child: Column(
title, mainAxisAlignment: MainAxisAlignment.center,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( children: [
fontWeight: FontWeight.bold, _buildPageHeader(
color: colorScheme.onSurface, context: context,
colorScheme: colorScheme,
icon: icon,
iconColor: iconColor,
title: title,
description: description,
badge: l10n.wizardOverviewBadge,
), ),
textAlign: TextAlign.center, if (features != null && features.isNotEmpty) ...[
), const SizedBox(height: 18),
const SizedBox(height: 16), _buildContentCard(
// Description colorScheme: colorScheme,
Text( child: Column(
description, children: features
style: Theme.of(context).textTheme.bodyLarge?.copyWith( .map(
color: colorScheme.onSurface.withValues(alpha: 0.7), (feature) => Padding(
height: 1.5, padding: const EdgeInsets.symmetric(vertical: 7),
), child: _buildFeatureRow(
textAlign: TextAlign.center, context,
), colorScheme,
if (features != null && features.isNotEmpty) ...[ feature,
const SizedBox(height: 32), ),
// Features list ),
...features.map((feature) => Padding( )
padding: const EdgeInsets.symmetric(vertical: 8.0), .toList(),
child: Row( ),
children: [
Icon(
feature.icon,
color: colorScheme.primary,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Text(
feature.text,
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
), ),
)), ],
], ],
const SizedBox(height: 20), ),
], ),
),
), ),
); );
} }
Widget _buildPageHeader({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required Color iconColor,
required String title,
required String description,
required String badge,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 56, color: iconColor),
),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
height: 1.4,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildContentCard({
required ColorScheme colorScheme,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: child,
);
}
Widget _buildFeatureRow(
BuildContext context,
ColorScheme colorScheme,
_FeatureItem feature, {
double iconSize = 22,
double gap = 14,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
SizedBox(width: gap),
Expanded(
child: Text(
feature.text,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
),
),
],
);
}
} }
class _FeatureItem { class _FeatureItem {
@@ -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,
});
}

View File

@@ -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,
);
}
} }

View File

@@ -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!;

View 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);
}
}

View File

@@ -2,13 +2,17 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/contact_group.dart';
import '../utils/key_comparison.dart'; import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage /// Service for persisting contacts to local storage
class ContactStorageService { class ContactStorageService {
static const String _contactsKey = 'stored_contacts'; static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
/// Save contacts to persistent storage /// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async { Future<void> saveContacts(List<Contact> contacts) async {
@@ -90,6 +94,81 @@ class ContactStorageService {
} }
} }
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
}
}
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_pendingAdvertsKey);
if (jsonString == null || jsonString.isEmpty) {
return const [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.whereType<Map<String, dynamic>>().toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
return const [];
}
}
Future<void> clearPendingAdverts() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingAdvertsKey);
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -123,6 +202,7 @@ class ContactStorageService {
'outPathLen': contact.outPathLen, 'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath), 'outPath': base64Encode(contact.outPath),
'advName': contact.advName, 'advName': contact.advName,
'nameOverride': contact.nameOverride,
'lastAdvert': contact.lastAdvert, 'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat, 'advLat': contact.advLat,
'advLon': contact.advLon, 'advLon': contact.advLon,
@@ -130,6 +210,15 @@ class ContactStorageService {
'telemetry': contact.telemetry != null 'telemetry': contact.telemetry != null
? _telemetryToJson(contact.telemetry!) ? _telemetryToJson(contact.telemetry!)
: null, : null,
'advertHistory': contact.advertHistory
.map(
(point) => {
'lat': point.location.latitude,
'lon': point.location.longitude,
'tsMillis': point.timestamp.millisecondsSinceEpoch,
},
)
.toList(),
}; };
} }
@@ -145,6 +234,7 @@ class ContactStorageService {
outPathLen: json['outPathLen'] as int, outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)), outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String, advName: json['advName'] as String,
nameOverride: json['nameOverride'] as String?,
lastAdvert: json['lastAdvert'] as int, lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int, advLat: json['advLat'] as int,
advLon: json['advLon'] as int, advLon: json['advLon'] as int,
@@ -152,6 +242,7 @@ class ContactStorageService {
telemetry: json['telemetry'] != null telemetry: json['telemetry'] != null
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>) ? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
: null, : null,
advertHistory: _advertHistoryFromJson(json['advertHistory']),
); );
} catch (e) { } catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e'); debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
@@ -203,4 +294,56 @@ class ContactStorageService {
return null; return null;
} }
} }
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
if (json is! List) return [];
final result = <AdvertLocation>[];
for (final item in json) {
if (item is! Map<String, dynamic>) continue;
final lat = item['lat'];
final lon = item['lon'];
final tsMillis = item['tsMillis'];
if (lat is! num || lon is! num || tsMillis is! int) continue;
result.add(
AdvertLocation(
location: LatLng(lat.toDouble(), lon.toDouble()),
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
),
);
}
return result;
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
'matchPrefixes': group.matchPrefixes,
'isAutoGroup': group.isAutoGroup,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
?.map((value) => value as String)
.toList(),
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
} }

View 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);
}
}

View File

@@ -0,0 +1,247 @@
import '../models/ble_packet_log.dart';
import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
String get payloadLabel {
final decodedRoute = route;
if (decodedRoute == null) {
return log.responseCode != null ? log.opcodeName : 'Unknown';
}
return payloadTypeLabel(decodedRoute.payloadType);
}
String? get payloadMeaning {
final decodedRoute = route;
if (decodedRoute == null) return null;
return payloadTypeMeaning(decodedRoute.payloadType);
}
String get routePreview {
final decodedRoute = route;
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
return 'Direct packet';
}
return decodedRoute.hopHashes
.map((hashHex) => '0x${hashHex.toUpperCase()}')
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
}
}
class LiveTrafficSnapshot {
final DateTime windowStart;
final Duration windowDuration;
final int packetsPerMinute;
final int rxCount;
final int txCount;
final int totalCount;
final double? avgSnrDb;
final double? latestSnrDb;
final double? avgRssiDbm;
final int? latestRssiDbm;
final int multiHopCount;
final double? avgHopCount;
final List<LiveTrafficEntry> visibleEntries;
final LiveTrafficBusyness busyness;
const LiveTrafficSnapshot({
required this.windowStart,
required this.windowDuration,
required this.packetsPerMinute,
required this.rxCount,
required this.txCount,
required this.totalCount,
required this.avgSnrDb,
required this.latestSnrDb,
required this.avgRssiDbm,
required this.latestRssiDbm,
required this.multiHopCount,
required this.avgHopCount,
required this.visibleEntries,
required this.busyness,
});
}
class LiveTrafficSummary {
static const Duration rollingWindow = Duration(seconds: 60);
static const int maxVisibleEntries = 120;
static const int logRxDataResponseCode = 0x88;
const LiveTrafficSummary._();
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
DateTime? clearedAt,
int? preferredHashSize,
Duration window = rollingWindow,
String? packetTypeFilter,
}) {
final windowStart = now.subtract(window);
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
final route = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: preferredHashSize,
);
entries.add(LiveTrafficEntry(log: log, route: route));
}
final filteredEntries = packetTypeFilter == null
? entries
: entries
.where((entry) => entry.payloadLabel == packetTypeFilter)
.toList();
var rxCount = 0;
var snrCount = 0;
var snrSum = 0.0;
var rssiCount = 0;
var rssiSum = 0.0;
double? latestSnrDb;
int? latestRssiDbm;
var multiHopCount = 0;
var hopCountTotal = 0;
var hopCountSamples = 0;
for (final entry in filteredEntries) {
rxCount += 1;
final rxInfo = entry.log.logRxDataInfo;
if (rxInfo?.snrDb != null) {
snrCount += 1;
snrSum += rxInfo!.snrDb!;
latestSnrDb = rxInfo.snrDb!;
}
if (rxInfo?.rssiDbm != null) {
rssiCount += 1;
rssiSum += rxInfo!.rssiDbm!.toDouble();
latestRssiDbm = rxInfo.rssiDbm!;
}
final route = entry.route;
if (route != null && route.hopCount > 0) {
hopCountSamples += 1;
hopCountTotal += route.hopCount;
if (route.hopCount > 1) {
multiHopCount += 1;
}
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute =
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
return LiveTrafficSnapshot(
windowStart: effectiveStart,
windowDuration: window,
packetsPerMinute: packetsPerMinute,
rxCount: rxCount,
txCount: txCount,
totalCount: totalCount,
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
latestSnrDb: latestSnrDb,
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);
}
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
if (ppm <= 5) return LiveTrafficBusyness.quiet;
if (ppm <= 20) return LiveTrafficBusyness.active;
return LiveTrafficBusyness.busy;
}
}

View File

@@ -15,6 +15,10 @@ class LocalePreferences {
Locale('fr'), // French Locale('fr'), // French
Locale('it'), // Italian Locale('it'), // Italian
Locale('el'), // Greek Locale('el'), // Greek
Locale('pt'), // Portuguese
Locale('tr'), // Turkish
Locale('pl'), // Polish
Locale('uk'), // Ukrainian
Locale('ru'), // Russian Locale('ru'), // Russian
Locale('zh'), // Chinese Locale('zh'), // Chinese
]; ];
@@ -66,6 +70,14 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Greek'; return 'Greek';
case 'pt':
return 'Portuguese';
case 'tr':
return 'Turkish';
case 'pl':
return 'Polish';
case 'uk':
return 'Ukrainian';
case 'ru': case 'ru':
return 'Русский'; return 'Русский';
case 'zh': case 'zh':
@@ -94,6 +106,14 @@ class LocalePreferences {
return 'Italiano'; return 'Italiano';
case 'el': case 'el':
return 'Ελληνικά'; return 'Ελληνικά';
case 'pt':
return 'Português';
case 'tr':
return 'Türkçe';
case 'pl':
return 'Polski';
case 'uk':
return 'Українська';
case 'ru': case 'ru':
return 'Русский'; return 'Русский';
case 'zh': case 'zh':

View File

@@ -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,17 +341,20 @@ class LocationTrackingService {
// Try to get initial position in background (non-blocking) // Try to get initial position in background (non-blocking)
// This will populate currentPosition but won't block tracking startup // This will populate currentPosition but won't block tracking startup
getCurrentPosition( getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
timeLimit: const Duration(seconds: 10), .then((position) {
retryCount: 1, if (position != null) {
).then((position) { debugPrint(
if (position != null) { '✅ [LocationTracking] Initial position acquired in background',
debugPrint('✅ [LocationTracking] Initial position acquired in background'); );
} }
}).catchError((error) { })
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error'); .catchError((error) {
// Not critical - position stream will eventually provide position debugPrint(
}); '⚠️ [LocationTracking] Background initial position failed: $error',
);
// Not critical - position stream will eventually provide position
});
// Start position stream immediately (don't wait for initial position) // Start position stream immediately (don't wait for initial position)
try { try {
@@ -296,6 +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;

View File

@@ -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;
}
}

View File

@@ -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;
}
} }

View File

@@ -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:

View File

@@ -5,17 +5,23 @@ import '../models/message.dart';
import '../models/message_contact_location.dart'; import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart'; import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart'; import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
class MessageStorageService { class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey = static const String _messageContactLocationsKey =
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey = static const String _messageReceptionDetailsKey =
'stored_message_reception_details'; 'stored_message_reception_details';
static const String _messageTransferDetailsKey = static const String _messageTransferDetailsKey =
'stored_message_transfer_details'; 'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
@@ -24,12 +30,21 @@ class MessageStorageService {
Map<String, MessageContactLocation> messageContactLocations = const {}, Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {}, Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {}, Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
}) async { }) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON // Convert messages to JSON and embed path bytes as a fallback so they
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); // survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent) // Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages final limitedList = jsonList.length > _maxStoredMessages
@@ -44,6 +59,7 @@ class MessageStorageService {
final locationJson = <String, dynamic>{}; final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{}; final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{}; final transferJson = <String, dynamic>{};
final routeMetadataJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) { for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) { if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson(); locationJson[entry.key] = entry.value.toJson();
@@ -59,6 +75,11 @@ class MessageStorageService {
transferJson[entry.key] = entry.value.toJson(); transferJson[entry.key] = entry.value.toJson();
} }
} }
for (final entry in messageRouteMetadata.entries) {
if (retainedMessageIds.contains(entry.key)) {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString( await prefs.setString(
_messageContactLocationsKey, _messageContactLocationsKey,
jsonEncode(locationJson), jsonEncode(locationJson),
@@ -71,6 +92,10 @@ class MessageStorageService {
_messageTransferDetailsKey, _messageTransferDetailsKey,
jsonEncode(transferJson), jsonEncode(transferJson),
); );
await prefs.setString(
_messageRouteMetadataKey,
jsonEncode(routeMetadataJson),
);
debugPrint( debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage', '✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -115,24 +140,36 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) { if (jsonString != null && jsonString.isNotEmpty) {
final value = entry.value; final decoded = jsonDecode(jsonString);
if (value is! Map<String, dynamic>) continue; if (decoded is Map<String, dynamic>) {
final snapshot = MessageReceptionDetails.fromJson(value); for (final entry in decoded.entries) {
if (snapshot != null) { final value = entry.value;
result[entry.key] = snapshot; if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
} }
} }
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result; return result;
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e'); debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -170,6 +207,32 @@ class MessageStorageService {
} }
} }
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageRouteMetadata>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
result[entry.key] = MessageRouteMetadata.fromJson(value);
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
return const {};
}
}
/// Load messages from persistent storage /// Load messages from persistent storage
Future<List<Message>> loadMessages() async { Future<List<Message>> loadMessages() async {
try { try {
@@ -206,12 +269,37 @@ class MessageStorageService {
await prefs.remove(_messageContactLocationsKey); await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey); await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');
} }
} }
Future<Set<String>> loadRemovedSarMarkerIds() async {
try {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
return const <String>{};
}
}
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
}
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -237,7 +325,12 @@ class MessageStorageService {
} }
/// Convert Message to JSON /// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) { Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return { return {
'id': message.id, 'id': message.id,
'messageType': message.messageType.name, 'messageType': message.messageType.name,
@@ -246,6 +339,7 @@ class MessageStorageService {
: null, : null,
'channelIdx': message.channelIdx, 'channelIdx': message.channelIdx,
'pathLen': message.pathLen, 'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value, 'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp, 'senderTimestamp': message.senderTimestamp,
'text': message.text, 'text': message.text,
@@ -297,9 +391,67 @@ class MessageStorageService {
}, },
) )
.toList(), .toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
}; };
} }
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message /// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) { Message? _messageFromJson(Map<String, dynamic> json) {
try { try {
@@ -316,6 +468,14 @@ class MessageStorageService {
: null, : null,
channelIdx: json['channelIdx'] as int?, channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int, pathLen: json['pathLen'] as int,
pathBytes: json['pathBytes'] is List
? Uint8List.fromList(
(json['pathBytes'] as List<dynamic>)
.whereType<num>()
.map((b) => b.toInt())
.toList(),
)
: null,
textType: MessageTextType.fromValue(json['textType'] as int), textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int, senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String, text: json['text'] as String,
@@ -413,4 +573,12 @@ class MessageStorageService {
return null; return null;
} }
} }
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
} }

View 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);
}
}

View 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;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/data/latest_all.dart' as tz;
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -13,14 +14,26 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin = final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
static const String _prefSarEnabled = 'notifications_sar_enabled';
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
static const String _prefMuteForeground = 'notifications_mute_foreground';
bool _isInitialized = false; bool _isInitialized = false;
bool _permissionGranted = false; bool _permissionGranted = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs // Notification IDs
static const int _sarNotificationId = 1000; static const int _sarNotificationId = 1000;
static const int _messageNotificationId = 2000; static const int _messageNotificationId = 2000;
static const int _updateNotificationId = 3000; static const int _updateNotificationId = 3000;
static const int _batteryNotificationId = 4000;
static const int _discoveryNotificationId = 5000;
// Notification channels // Notification channels
static const String _urgentChannelId = 'sar_urgent'; static const String _urgentChannelId = 'sar_urgent';
@@ -38,12 +51,31 @@ class NotificationService {
static const String _updateChannelDescription = static const String _updateChannelDescription =
'Notifications for available app updates'; 'Notifications for available app updates';
static const String _batteryChannelId = 'battery_alerts';
static const String _batteryChannelName = 'Battery Alerts';
static const String _batteryChannelDescription =
'Notifications when a device or contact battery is low';
static const String _discoveryChannelId = 'discovery_alerts';
static const String _discoveryChannelName = 'Discovery Alerts';
static const String _discoveryChannelDescription =
'Notifications when new contacts are discovered';
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
bool get muteForegroundNotifications => _muteForegroundNotifications;
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
/// Initialize notification service /// Initialize notification service
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
try { try {
debugPrint('📬 [NotificationService] Initializing...'); debugPrint('📬 [NotificationService] Initializing...');
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
_lifecycleState =
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
// Initialize timezone data // Initialize timezone data
tz.initializeTimeZones(); tz.initializeTimeZones();
@@ -72,8 +104,15 @@ class NotificationService {
onDidReceiveNotificationResponse: _onNotificationResponse, onDidReceiveNotificationResponse: _onNotificationResponse,
); );
final launchDetails = await _notificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_launchPayload = launchDetails?.notificationResponse?.payload;
}
// Request permissions // Request permissions
await _requestPermissions(); await _requestPermissions();
await _loadPreferences();
// Create notification channels (Android) // Create notification channels (Android)
await _createNotificationChannels(); await _createNotificationChannels();
@@ -132,6 +171,46 @@ class NotificationService {
} }
} }
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
}
Future<void> setMessageNotificationsEnabled(bool value) async {
_messageNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMessagesEnabled, value);
}
Future<void> setSarNotificationsEnabled(bool value) async {
_sarNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefSarEnabled, value);
}
Future<void> setUpdateNotificationsEnabled(bool value) async {
_updateNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefUpdatesEnabled, value);
}
Future<void> setMuteForegroundNotifications(bool value) async {
_muteForegroundNotifications = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMuteForeground, value);
}
void handleAppLifecycleStateChanged(AppLifecycleState state) {
_lifecycleState = state;
}
bool _shouldSuppressForegroundNotifications() {
return _muteForegroundNotifications && isAppInForeground;
}
/// Create notification channels for Android /// Create notification channels for Android
Future<void> _createNotificationChannels() async { Future<void> _createNotificationChannels() async {
try { try {
@@ -177,9 +256,31 @@ class NotificationService {
showBadge: true, showBadge: true,
); );
const batteryChannel = AndroidNotificationChannel(
_batteryChannelId,
_batteryChannelName,
description: _batteryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
const discoveryChannel = AndroidNotificationChannel(
_discoveryChannelId,
_discoveryChannelName,
description: _discoveryChannelDescription,
importance: Importance.high,
playSound: true,
enableVibration: true,
showBadge: true,
);
await androidPlugin.createNotificationChannel(urgentChannel); await androidPlugin.createNotificationChannel(urgentChannel);
await androidPlugin.createNotificationChannel(messagesChannel); await androidPlugin.createNotificationChannel(messagesChannel);
await androidPlugin.createNotificationChannel(updateChannel); await androidPlugin.createNotificationChannel(updateChannel);
await androidPlugin.createNotificationChannel(batteryChannel);
await androidPlugin.createNotificationChannel(discoveryChannel);
debugPrint('✅ [NotificationService] Created notification channels'); debugPrint('✅ [NotificationService] Created notification channels');
} catch (e) { } catch (e) {
debugPrint('⚠️ [NotificationService] Error creating channels: $e'); debugPrint('⚠️ [NotificationService] Error creating channels: $e');
@@ -201,6 +302,12 @@ class NotificationService {
} }
} }
String? consumeLaunchPayload() {
final payload = _launchPayload;
_launchPayload = null;
return payload;
}
/// Show urgent notification for SAR marker /// Show urgent notification for SAR marker
Future<void> showSarNotification({ Future<void> showSarNotification({
required SarMarkerType type, required SarMarkerType type,
@@ -222,6 +329,16 @@ class NotificationService {
); );
return; return;
} }
if (!_sarNotificationsEnabled) {
debugPrint(' [NotificationService] SAR notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping SAR notification',
);
return;
}
try { try {
// Generate unique notification ID based on timestamp // Generate unique notification ID based on timestamp
@@ -394,6 +511,16 @@ class NotificationService {
); );
return; return;
} }
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping message notification',
);
return;
}
try { try {
// Generate unique notification ID based on timestamp // Generate unique notification ID based on timestamp
@@ -402,10 +529,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000); (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body // Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage final title = isChannelMessage
? (localizations != null ? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}' ? '${localizations.channel}: $resolvedChannelName'
: 'Channel: ${channelName ?? "Public"}') : 'Channel: $resolvedChannelName')
: (localizations != null : (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName' ? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName'); : 'New message from $senderName');
@@ -544,13 +674,23 @@ class NotificationService {
); );
return; return;
} }
if (!_updateNotificationsEnabled) {
debugPrint(' [NotificationService] Update notifications disabled');
return;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping update notification',
);
return;
}
try { try {
// Build notification title and body // Build notification title and body
final title = localizations?.updateAvailable ?? 'App Update Available'; final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n' ? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion' '${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion'; : 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details // Android notification details
@@ -614,4 +754,169 @@ class NotificationService {
); );
} }
} }
Future<bool> showLowBatteryNotification({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return false;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return false;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return false;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping low battery notification',
);
return false;
}
final roundedPercent = batteryPercent.round().clamp(0, 100);
final title = isCurrentDevice
? 'Device battery low'
: 'Contact battery low';
final body = isCurrentDevice
? '$nodeName battery is at $roundedPercent%.'
: '$nodeName is at $roundedPercent% battery.';
final notificationId =
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_batteryChannelId,
_batteryChannelName,
channelDescription: _batteryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: '$roundedPercent%',
),
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'battery_alerts',
subtitle: '$roundedPercent%',
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'battery:$nodeId',
);
debugPrint(
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing low battery notification: $e',
);
return false;
}
}
Future<bool> showContactDiscoveredNotification({
required String contactKey,
String? contactName,
}) async {
if (!_isInitialized) return false;
if (!_permissionGranted) return false;
if (!_messageNotificationsEnabled) return false;
if (_shouldSuppressForegroundNotifications()) return false;
final shortKey = contactKey.length > 12
? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase();
final title = 'New contact discovered';
final resolvedName = contactName?.trim();
final body = resolvedName != null && resolvedName.isNotEmpty
? '$resolvedName is available in Discovery.'
: 'New contact $shortKey is available in Discovery.';
final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_discoveryChannelId,
_discoveryChannelName,
channelDescription: _discoveryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'discovery_alerts',
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
),
payload: 'discovery:$contactKey',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing discovery notification: $e',
);
return false;
}
}
}
class _LifecycleObserver with WidgetsBindingObserver {
_LifecycleObserver(this._service);
final NotificationService _service;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_service.handleAppLifecycleStateChanged(state);
}
} }

View File

@@ -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()) {

View 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();
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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'));
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -0,0 +1,174 @@
import '../models/contact.dart';
class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
final List<String> matchPrefixes;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
List<String>? matchPrefixes,
}) : matchPrefixes = matchPrefixes ?? const <String>[];
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static String? inferredGroupLabelForContact(Contact contact) {
return _extractPrefix(contact.displayName)?.label;
}
static bool contactMatchesInferredGroupLabel(Contact contact, String query) {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return false;
}
final groupLabel = inferredGroupLabelForContact(contact)?.toLowerCase();
return groupLabel?.contains(normalizedQuery) ?? false;
}
static String? sharedInferredGroupLabel(List<Contact> contacts) {
String? sharedLabel;
for (final contact in contacts) {
final label = inferredGroupLabelForContact(contact);
if (label == null) {
return null;
}
if (sharedLabel == null) {
sharedLabel = label;
continue;
}
if (sharedLabel != label) {
return null;
}
}
return sharedLabel;
}
static String? sharedParentGroupLabel(List<String> labels) {
if (labels.isEmpty) {
return null;
}
var commonPrefix = labels.first;
for (final label in labels.skip(1)) {
final maxLength = commonPrefix.length < label.length
? commonPrefix.length
: label.length;
var matchLength = 0;
while (matchLength < maxLength &&
commonPrefix.codeUnitAt(matchLength) ==
label.codeUnitAt(matchLength)) {
matchLength++;
}
commonPrefix = commonPrefix.substring(0, matchLength);
if (commonPrefix.isEmpty) {
return null;
}
}
final separatorIndex = commonPrefix.lastIndexOf(RegExp(r'[-_/:]'));
if (separatorIndex < 1) {
return null;
}
final parentLabel = commonPrefix.substring(0, separatorIndex + 1);
return parentLabel.length >= 3 ? parentLabel : null;
}
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<InferredContactGroup> inferGroups(
List<Contact> contacts, {
int minGroupSize = 4,
int? maxNamedGroups,
String? overflowGroupLabel,
}) {
final sortedContacts = sortByLastSeen(contacts);
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
if (prefix == null) continue;
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final rankedGroups = <InferredContactGroup>[];
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
rankedGroups.add(
InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
matchPrefixes: [groupLabels[entry.key] ?? entry.key],
),
);
}
rankedGroups.sort((a, b) => b.latestSeen.compareTo(a.latestSeen));
if (maxNamedGroups != null &&
overflowGroupLabel != null &&
rankedGroups.length > maxNamedGroups) {
final retainedGroups = rankedGroups.take(maxNamedGroups).toList();
final overflowGroups = rankedGroups.skip(maxNamedGroups).toList();
final parentLabel = sharedParentGroupLabel([
for (final group in overflowGroups) ...group.matchPrefixes,
]);
if (parentLabel != null) {
final overflowContacts = overflowGroups
.expand((group) => group.contacts)
.toList();
return [
...retainedGroups,
InferredContactGroup(
key: parentLabel,
label: parentLabel,
contacts: sortByLastSeen(overflowContacts),
matchPrefixes: [parentLabel],
),
];
}
return rankedGroups;
}
return rankedGroups;
}
static _GroupPrefix? _extractPrefix(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final match = _prefixedNamePattern.firstMatch(trimmed);
if (match == null) return null;
final rawPrefix = match.group(1);
final separator = match.group(2);
if (rawPrefix == null || separator == null) return null;
return _GroupPrefix(
key: rawPrefix.toUpperCase(),
label: '$rawPrefix$separator',
);
}
}
class _GroupPrefix {
final String key;
final String label;
const _GroupPrefix({required this.key, required this.label});
}

View 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;
}
}

View File

@@ -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();
}
} }

View File

@@ -1,17 +1,24 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/messages_provider.dart';
/// Extension for Message to provide localized delivery status /// Extension for Message to provide localized delivery status
extension MessageLocalization on Message { extension MessageLocalization on Message {
/// Get localized delivery status text /// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) { String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final messagesProvider = context.read<MessagesProvider>();
final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status // For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context); final latestMeta = _formatEchoMeta(context);
if (echoCount == 0) { if (echoCount == 0) {
if (messagesProvider.hasChannelSendWarning(id)) {
return 'Broadcast may have failed';
}
return l10n.broadcast; // "Broadcast (no echoes yet)" return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) { } else if (echoCount == 1) {
return latestMeta == null ? '1 node' : '1 node • $latestMeta'; return latestMeta == null ? '1 node' : '1 node • $latestMeta';
@@ -26,29 +33,51 @@ extension MessageLocalization on Message {
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
if (isContactMessage) { if (isContactMessage) {
if (retryAttempt > 0) { if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3'; final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
} }
return l10n.pending; return routeMetadata == null
? l10n.pending
: '${l10n.pending}${routeMetadata.modeLabel}';
} }
return l10n.sending; return l10n.sending;
case MessageDeliveryStatus.sent: case MessageDeliveryStatus.sent:
return l10n.sent; return routeMetadata == null
? l10n.sent
: '${l10n.sent}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
if (retryAttempt > 0 && roundTripTimeMs != null) { if (retryAttempt > 0 && roundTripTimeMs != null) {
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/3'; final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
} }
if (retryAttempt > 0) { if (retryAttempt > 0) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3'; final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
} }
if (roundTripTimeMs != null) { if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!); return routeMetadata == null
? l10n.deliveredWithTime(roundTripTimeMs!)
: '${l10n.deliveredWithTime(roundTripTimeMs!)}${routeMetadata.modeLabel}';
} }
return l10n.delivered; return routeMetadata == null
? l10n.delivered
: '${l10n.delivered}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
if (retryAttempt > 0) { if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3'; final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
} }
return l10n.failed; return routeMetadata == null
? l10n.failed
: '${l10n.failed}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.received: case MessageDeliveryStatus.received:
return ''; return '';
} }

View File

@@ -0,0 +1,239 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final List<MeshMapNode> candidates;
final int matchCount;
final bool usedOnlineFallback;
final int selectedIndex;
const ResolvedTraceNode({
required this.candidates,
required this.matchCount,
required this.usedOnlineFallback,
this.selectedIndex = 0,
});
MeshMapNode? get node =>
candidates.isEmpty ? null : candidates[selectedIndex];
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
bool get canCycle => candidates.length > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
String? get cycleSummary =>
canCycle ? 'tap to cycle ${selectedIndex + 1}/$matchCount' : null;
ResolvedTraceNode cycle() {
if (!canCycle) return this;
return ResolvedTraceNode(
candidates: candidates,
matchCount: matchCount,
usedOnlineFallback: usedOnlineFallback,
selectedIndex: (selectedIndex + 1) % candidates.length,
);
}
}
class TraceNodeResolver {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
candidates: <MeshMapNode>[],
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
candidates: List<MeshMapNode>.unmodifiable(pool),
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static List<ResolvedTraceNode> alignPathSelections({
required List<ResolvedTraceNode> nodes,
MeshMapNode? startNode,
MeshMapNode? endNode,
}) {
if (nodes.isEmpty || nodes.any((node) => node.candidates.isEmpty)) {
return nodes;
}
final candidateCosts = List.generate(
nodes.length,
(_) => <double>[],
growable: false,
);
final previousChoice = List.generate(
nodes.length,
(_) => <int>[],
growable: false,
);
for (var i = 0; i < nodes.length; i++) {
final currentCandidates = nodes[i].candidates;
candidateCosts[i] = List<double>.filled(
currentCandidates.length,
double.infinity,
);
previousChoice[i] = List<int>.filled(currentCandidates.length, -1);
for (var j = 0; j < currentCandidates.length; j++) {
final current = currentCandidates[j];
if (i == 0) {
candidateCosts[i][j] = startNode == null
? 0
: _distanceBetweenNodes(startNode, current);
continue;
}
final previousCandidates = nodes[i - 1].candidates;
for (var k = 0; k < previousCandidates.length; k++) {
final candidateCost =
candidateCosts[i - 1][k] +
_distanceBetweenNodes(previousCandidates[k], current);
if (candidateCost < candidateCosts[i][j]) {
candidateCosts[i][j] = candidateCost;
previousChoice[i][j] = k;
}
}
}
}
var bestLastIndex = 0;
var bestLastCost = double.infinity;
final lastCandidates = nodes.last.candidates;
for (var i = 0; i < lastCandidates.length; i++) {
final endCost = endNode == null
? 0
: _distanceBetweenNodes(lastCandidates[i], endNode);
final totalCost = candidateCosts.last[i] + endCost;
if (totalCost < bestLastCost) {
bestLastCost = totalCost;
bestLastIndex = i;
}
}
final selectedIndices = List<int>.filled(nodes.length, 0);
selectedIndices[nodes.length - 1] = bestLastIndex;
for (var i = nodes.length - 1; i > 0; i--) {
selectedIndices[i - 1] = previousChoice[i][selectedIndices[i]];
}
return List<ResolvedTraceNode>.generate(nodes.length, (index) {
final resolved = nodes[index];
return ResolvedTraceNode(
candidates: resolved.candidates,
matchCount: resolved.matchCount,
usedOnlineFallback: resolved.usedOnlineFallback,
selectedIndex: selectedIndices[index],
);
}, growable: false);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
return double.maxFinite;
}
static double _distanceBetweenNodes(MeshMapNode a, MeshMapNode b) {
return _distance.as(
LengthUnit.Meter,
LatLng(a.latitude, a.longitude),
LatLng(b.latitude, b.longitude),
);
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

Some files were not shown because too many files have changed in this diff Show More