Compare commits

...

58 Commits

Author SHA1 Message Date
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
125 changed files with 35847 additions and 22132 deletions

View File

@@ -280,7 +280,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3

1
.gitignore vendored
View File

@@ -80,6 +80,7 @@ create_feature_graphic.py
# Tool caches and local state
.cachebro/
.osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config)
.claude/settings.local.json

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

View File

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

View File

@@ -1,5 +1,6 @@
import UIKit
import Flutter
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,7 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

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

View File

@@ -5,24 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00029">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.339599">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
<testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994">
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Gem.activate_and_load_bin_path&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
</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

View File

@@ -29,9 +29,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get disconnect => '断开连接';
@override
String get scanningForDevices => '正在扫描设备...';
@override
String get noDevicesFound => '未找到设备';
@@ -59,27 +56,14 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get failedToGetGpsLocation => '获取 GPS 位置失败';
@override
String advertisedAtLocation(String latitude, String longitude) {
return '已广播位置 $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return '广播失败: $error';
}
@override
String reconnecting(int attempt, int max) {
return '正在重新连接... ($attempt/$max)';
}
@override
String get cancelReconnection => '取消重新连接';
@override
String get mapManagement => '地图管理';
@override
String get general => '通用';
@@ -125,12 +109,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get displayPacketActivity => '在顶部栏显示数据包活动指示器';
@override
String get simpleMode => '简洁模式';
@override
String get simpleModeDescription => '在消息和联系人中隐藏非必要信息';
@override
String get disableMap => '禁用地图';
@@ -143,72 +121,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get chooseLanguage => '选择语言';
@override
String get english => '英语';
@override
String get slovenian => '斯洛文尼亚语';
@override
String get croatian => '克罗地亚语';
@override
String get german => '德语';
@override
String get spanish => '西班牙语';
@override
String get french => '法语';
@override
String get italian => '意大利语';
@override
String get locationBroadcasting => '位置广播';
@override
String get autoLocationTracking => '自动位置跟踪';
@override
String get automaticallyBroadcastPosition => '自动广播位置更新';
@override
String get configureTracking => '配置跟踪';
@override
String get distanceAndTimeThresholds => '距离和时间阈值';
@override
String get locationTrackingConfiguration => '位置跟踪配置';
@override
String get configureWhenLocationBroadcasts => '配置何时向 Mesh 网络发送位置广播';
@override
String get minimumDistance => '最小距离';
@override
String broadcastAfterMoving(String distance) {
return '仅在移动 $distance 米后广播';
}
@override
String get maximumDistance => '最大距离';
@override
String alwaysBroadcastAfterMoving(String distance) {
return '移动 $distance 米后始终广播';
}
@override
String get minimumTimeInterval => '最小时间间隔';
@override
String alwaysBroadcastEvery(String duration) {
return '每隔 $duration 始终广播';
}
@override
String get save => '保存';
@@ -244,12 +156,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get moreInfo => '更多信息';
@override
String get learnMoreAbout => '了解更多关于 MeshCore 搜救助手的信息';
@override
String get developer => '开发者';
@override
String get packageName => '包名';
@@ -321,9 +227,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get refresh => '刷新';
@override
String get sendDirectMessage => '发送';
@override
String get resetPath => '重置路径 (重新路由)';
@@ -441,21 +344,6 @@ class AppLocalizationsZh extends AppLocalizations {
return '共享 $count 个绘图';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return '已发送 $count 个地图绘制到 $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return '已将 $success/$total 个绘图共享到 $roomName';
}
@override
String get showReceivedDrawings => '显示接收到的绘图';
@@ -477,9 +365,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get clearAll => '全部清除';
@override
String get noLocalDrawings => '没有可共享的本地绘图';
@override
String get publicChannel => '公共频道';
@@ -489,37 +374,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get storedPermanently => '永久存储在房间中';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '已将 $count 个地图绘制发送到公共频道';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '已将 $success/$total 个绘图共享到公共频道';
}
@override
String get notConnectedToDevice => '未连接到设备';
@override
String get directMessage => '直连消息';
@override
String directMessageSentTo(String contactName) {
return '直连消息已发送给 $contactName';
}
@override
String failedToSend(String error) {
return '发送失败:$error';
}
@override
String directMessageInfo(String contactName) {
return '此消息将直连发送给 $contactName。它也将出现在主消息流中。';
}
@override
String get typeYourMessage => '输入您的消息...';
@@ -547,9 +404,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get location => '位置';
@override
String get myLocation => '我的位置';
@override
String get fromMap => '来自地图';
@@ -610,9 +464,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get flood => '泛洪';
@override
String get admin => '管理员';
@override
String get loggedIn => '已登录';
@@ -622,29 +473,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get distance => '距离';
@override
String pingingDirect(String name) {
return '正在 Ping $name(通过路径直连发送)...';
}
@override
String pingingFlood(String name) {
return '正在 Ping $name(泛洪 - 无路径)...';
}
@override
String directPingTimeout(String name) {
return '直连 Ping 超时 - 正在使用泛洪重试 $name...';
}
@override
String pingSuccessful(String name, String fallback) {
return '成功 Ping 到 $name$fallback';
}
@override
String get viaFloodingFallback => '(通过泛洪回退)';
@override
String pingFailed(String name) {
return 'Ping $name 失败 - 未收到响应';
@@ -655,16 +488,6 @@ class AppLocalizationsZh extends AppLocalizations {
return '您确定要删除“$name”吗?\n\n这将从应用程序和配套的无线电设备中移除该联系人。';
}
@override
String removingContact(String name) {
return '正在移除 $name...';
}
@override
String contactRemoved(String name) {
return '联系人“$name”已移除';
}
@override
String failedToRemoveContact(String error) {
return '移除联系人失败:$error';
@@ -709,11 +532,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get telemetry => '遥测';
@override
String requestingTelemetry(String name) {
return '正在向 $name 请求遥测数据...';
}
@override
String get voltage => '电压';
@@ -785,9 +603,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get showAll => '显示全部';
@override
String get nearbyContacts => '附近联系人';
@override
String get locationUnavailable => '位置不可用';
@@ -827,169 +642,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get channels => '频道';
@override
String get cacheStatistics => '缓存统计';
@override
String get totalTiles => '总瓦片数';
@override
String get cacheSize => '缓存大小';
@override
String get storeName => '存储名称';
@override
String get noCacheStatistics => '没有可用的缓存统计信息';
@override
String get downloadRegion => '下载区域';
@override
String get mapLayer => '地图图层';
@override
String get regionBounds => '区域边界';
@override
String get north => '';
@override
String get south => '';
@override
String get east => '';
@override
String get west => '西';
@override
String get zoomLevels => '缩放级别';
@override
String minZoom(int zoom) {
return '最小:$zoom';
}
@override
String maxZoom(int zoom) {
return '最大:$zoom';
}
@override
String get downloadingDots => '正在下载...';
@override
String get cancelDownload => '取消下载';
@override
String get downloadRegionButton => '下载区域';
@override
String get downloadNote => '注意:大的区域或高缩放级别可能需要大量时间和存储空间。';
@override
String get cacheManagement => '缓存管理';
@override
String get clearAllMaps => '清除所有地图';
@override
String get clearMapsConfirmTitle => '清除所有地图';
@override
String get clearMapsConfirmMessage => '您确定要删除所有下载的地图吗?此操作无法撤销。';
@override
String get mapDownloadCompleted => '地图下载完成!';
@override
String get cacheClearedSuccessfully => '缓存清除成功!';
@override
String get downloadCancelled => '下载已取消';
@override
String get startingDownload => '正在开始下载...';
@override
String get downloadingMapTiles => '正在下载地图瓦片...';
@override
String get downloadCompletedSuccessfully => '下载成功完成!';
@override
String get cancellingDownload => '正在取消下载...';
@override
String errorLoadingStats(String error) {
return '加载统计信息时出错:$error';
}
@override
String downloadFailed(String error) {
return '下载失败:$error';
}
@override
String cancelFailed(String error) {
return '取消失败:$error';
}
@override
String clearCacheFailed(String error) {
return '清除缓存失败:$error';
}
@override
String minZoomError(String error) {
return '最小缩放:$error';
}
@override
String maxZoomError(String error) {
return '最大缩放:$error';
}
@override
String get minZoomGreaterThanMax => '最小缩放必须小于或等于最大缩放';
@override
String get selectMapLayer => '选择地图图层';
@override
String get mapOptions => '地图选项';
@override
String get showLegend => '显示图例';
@override
String get displayMarkerTypeCounts => '显示标记类型计数';
@override
String get rotateMapWithHeading => '随航向旋转地图';
@override
String get mapFollowsDirection => '地图在移动时跟随您的方向';
@override
String get resetMapRotation => '重置旋转';
@override
String get resetMapRotationTooltip => '重置地图方向为北';
@override
String get showMapDebugInfo => '显示地图调试信息';
@override
String get displayZoomLevelBounds => '显示缩放级别和边界';
@override
String get fullscreenMode => '全屏模式';
@override
String get hideUiFullMapView => '隐藏所有 UI 控件以获得全地图视图';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1008,12 +663,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get googleTerrain => 'Google 地形图';
@override
String get downloadVisibleArea => '下载可见区域';
@override
String get initializingMap => '正在初始化地图...';
@override
String get dragToPosition => '拖动以定位';
@@ -1029,49 +678,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get sarAlert => '搜救警报';
@override
String get messageSentToPublicChannel => '消息已发送到公共频道';
@override
String get pleaseSelectRoomToSendSar => '请选择一个房间发送搜救标记';
@override
String failedToSendSarMarker(String error) {
return '发送搜救标记失败:$error';
}
@override
String sarMarkerSentTo(String roomName) {
return '搜救标记已发送到 $roomName';
}
@override
String get notConnectedCannotSync => '未连接 - 无法同步消息';
@override
String syncedMessageCount(int count) {
return '已同步 $count 条消息';
}
@override
String get noNewMessages => '没有新消息';
@override
String syncFailed(String error) {
return '同步失败:$error';
}
@override
String get failedToResendMessage => '重新发送消息失败';
@override
String get retryingMessage => '正在重试消息...';
@override
String retryFailed(String error) {
return '重试失败:$error';
}
@override
String get textCopiedToClipboard => '文本已复制到剪贴板';
@@ -1081,9 +687,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cannotReplyContactNotFound => '无法回复:未找到联系人';
@override
String get messageDeleted => '消息已删除';
@override
String get copyText => '复制文本';
@@ -1118,12 +721,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get sarLocationShare => '搜救位置';
@override
String get locationShared => '位置已共享';
@override
String get refreshedContacts => '联系人已刷新';
@override
String get justNow => '刚刚';
@@ -1231,77 +828,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get you => '';
@override
String get offlineVectorMaps => '离线矢量地图';
@override
String get offlineVectorMapsDescription =>
'导入和管理离线矢量地图瓦片MBTiles 格式),以便在没有互联网连接时使用';
@override
String get importMbtiles => '导入 MBTiles 文件';
@override
String get importMbtilesNote =>
'支持带有矢量瓦片PBF/MVT 格式)的 MBTiles 文件。Geofabrik 的数据提取非常好用!';
@override
String get noMbtilesFiles => '未找到离线矢量地图';
@override
String get mbtilesImportedSuccessfully => 'MBTiles 文件导入成功';
@override
String get failedToImportMbtiles => '导入 MBTiles 文件失败';
@override
String get deleteMbtilesConfirmTitle => '删除离线地图';
@override
String deleteMbtilesConfirmMessage(String name) {
return '您确定要删除“$name”吗?这将永久移除该离线地图。';
}
@override
String get mbtilesDeletedSuccessfully => '离线地图删除成功';
@override
String get failedToDeleteMbtiles => '删除离线地图失败';
@override
String get importExportCachedTiles => '导入/导出缓存的瓦片';
@override
String get importExportDescription => '在设备之间备份、共享和恢复下载的地图瓦片';
@override
String get exportTilesToFile => '导出瓦片到文件';
@override
String get importTilesFromFile => '从文件导入瓦片';
@override
String get selectExportLocation => '选择导出位置';
@override
String get selectImportFile => '选择瓦片存档';
@override
String get exportingTiles => '正在导出瓦片...';
@override
String get importingTiles => '正在导入瓦片...';
@override
String exportSuccess(int count) {
return '成功导出 $count 个瓦片';
}
@override
String importSuccess(int count) {
return '成功导入 $count 个存储';
}
@override
String exportFailed(String error) {
return '导出失败:$error';
@@ -1312,38 +838,12 @@ class AppLocalizationsZh extends AppLocalizations {
return '导入失败:$error';
}
@override
String get exportNote => '创建一个压缩存档(.fmtc文件可以共享并在其他设备上导入。';
@override
String get importNote => '从先前导出的存档文件导入地图瓦片。瓦片将与现有缓存合并。';
@override
String get noTilesToExport => '没有可导出的瓦片';
@override
String archiveContainsStores(int count) {
return '存档包含 $count 个存储';
}
@override
String get vectorTiles => '矢量瓦片';
@override
String get schema => '模式';
@override
String get unknown => '未知';
@override
String get bounds => '边界';
@override
String get onlineLayers => '在线图层';
@override
String get offlineLayers => '离线图层';
@override
String get locationTrail => '位置轨迹';
@@ -1380,47 +880,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trailControls => '轨迹控制';
@override
String get exportTrailToGpx => '导出轨迹到 GPX';
@override
String get importTrailFromGpx => '从 GPX 导入轨迹';
@override
String get trailExportedSuccessfully => '轨迹导出成功!';
@override
String get failedToExportTrail => '导出轨迹失败';
@override
String failedToImportTrail(String error) {
return '导入轨迹失败:$error';
}
@override
String get importTrail => '导入轨迹';
@override
String importTrailQuestion(int pointCount) {
return '导入包含 $pointCount 个点的轨迹吗?\n\n您可以替换当前轨迹,或将其与现有轨迹并排查看。';
}
@override
String get viewAlongside => '并排查看';
@override
String get replaceCurrent => '替换当前';
@override
String trailImported(int pointCount) {
return '轨迹已导入!($pointCount 个点)';
}
@override
String trailReplaced(int pointCount) {
return '轨迹已替换!($pointCount 个点)';
}
@override
String get contactTrails => '联系人轨迹';
@@ -1672,18 +1131,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get noRoomsFound => '未找到房间';
@override
String get noContactsOrRoomsAvailable => '没有可用的联系人或房间';
@override
String get noRecipientsAvailable => '没有可用的收件人';
@override
String get noChannelsFound => '未找到频道';
@override
String get messagesWillBeSentToPublicChannel => '消息将发送到公共频道';
@override
String get newMessage => '新消息';
@@ -1855,9 +1308,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get rectangleDrawing => '矩形绘图';
@override
String get coordinatesCopiedToClipboard => '坐标已复制到剪贴板';
@override
String get manualCoordinates => '手动输入坐标';
@@ -1870,31 +1320,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get longitudeLabel => '经度';
@override
String get invalidLatitude => '无效的纬度(-90 到 90';
@override
String get invalidLongitude => '无效的经度(-180 到 180';
@override
String get exampleCoordinates => '例如46.0569, 14.5058';
@override
String get drawingShared => '地图绘制';
@override
String get drawingHidden => '绘图已从地图上隐藏';
@override
String alreadyShared(int count) {
return '$count 个已共享';
}
@override
String newDrawingsShared(int count, String plural) {
return '已共享 $count 个新绘图';
}
@override
String get shareDrawing => '共享绘图';
@@ -1948,12 +1376,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get forestRoads => '森林道路';
@override
String get showCadastralParcels => '显示地籍地块';
@override
String get showForestRoads => '显示森林道路';
@override
String get wmsOverlays => 'WMS 覆盖层';
@@ -2054,9 +1476,6 @@ class AppLocalizationsZh extends AppLocalizations {
return '删除频道失败:$error';
}
@override
String get allChannelSlotsInUse => '所有频道槽位都已在使用中(最多 39 个自定义频道)';
@override
String get createChannel => '创建频道';
@@ -2095,19 +1514,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get wizardConnectingFeature3 => '完全离线工作 - 无需互联网';
@override
String get wizardSimpleModeTitle => '简洁模式';
@override
String get wizardSimpleModeDescription =>
'刚接触 Mesh 网络?启用简洁模式以获得仅包含基本功能的简化界面。';
@override
String get wizardSimpleModeFeature1 => '对初学者友好的界面,包含核心功能';
@override
String get wizardSimpleModeFeature2 => '随时在设置中切换到高级模式';
@override
String get wizardChannelTitle => '频道';
@@ -2250,4 +1656,175 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get requestingVoice => '正在请求语音';
@override
String get device => '设备';
@override
String get change => '更改';
@override
String get wizardOverviewDescription =>
'此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。';
@override
String get wizardOverviewFeature1 => '在主消息标签页中发送私聊消息、房间消息和频道消息。';
@override
String get wizardOverviewFeature2 => '通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。';
@override
String get wizardOverviewFeature3 => '通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。';
@override
String get wizardMessagingTitle => '消息与现场报告';
@override
String get wizardMessagingDescription => '这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。';
@override
String get wizardMessagingFeature1 => '通过一个编辑器发送私聊消息、房间消息和频道流量。';
@override
String get wizardMessagingFeature2 => '为常见现场报告创建 SAR 更新和可复用的 SAR 模板。';
@override
String get wizardMessagingFeature3 => '传输语音会话和图像,并在界面中显示进度和空口时间估算。';
@override
String get wizardConnectDeviceTitle => '连接设备';
@override
String get wizardConnectDeviceDescription =>
'连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。';
@override
String get wizardSetupBadge => '设置';
@override
String get wizardOverviewBadge => '概览';
@override
String wizardConnectedToDevice(String deviceName) {
return '已连接到 $deviceName';
}
@override
String get wizardNoDeviceConnected => '尚未连接设备';
@override
String get wizardSkipForNow => '暂时跳过';
@override
String get wizardDeviceNameLabel => '设备名称';
@override
String get wizardDeviceNameHelp => '此名称会广播给其他 MeshCore 用户。';
@override
String get wizardConfigRegionLabel => '配置区域';
@override
String get wizardConfigRegionHelp =>
'使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。';
@override
String get wizardPresetNote1 => '请确认所选预设符合你所在地区的无线电规定。';
@override
String get wizardPresetNote2 => '该列表与 MeshCore 配置工具的官方预设源保持一致。';
@override
String get wizardPresetNote3 => '在引导设置中默认保持选中 EU/UK (Narrow)。';
@override
String get wizardSaving => '正在保存...';
@override
String get wizardSaveAndContinue => '保存并继续';
@override
String get wizardEnterDeviceName => '继续前请输入设备名称。';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '已保存 $deviceName,配置为 $presetName';
}
@override
String get wizardNetworkTitle => '联系人、房间和中继器';
@override
String get wizardNetworkDescription => '联系人标签页会整理你发现的网络以及随着时间学习到的路由。';
@override
String get wizardNetworkFeature1 => '在一个列表中查看团队成员、中继器、房间、频道和待处理广播。';
@override
String get wizardNetworkFeature2 => '当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。';
@override
String get wizardNetworkFeature3 => '无需离开应用即可创建频道并管理网络目的地。';
@override
String get wizardMapOpsTitle => '地图、轨迹与共享几何';
@override
String get wizardMapOpsDescription => '应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。';
@override
String get wizardMapOpsFeature1 => '在地图上跟踪你自己的位置、队友位置和移动轨迹。';
@override
String get wizardMapOpsFeature2 => '打开消息中的绘图,内联预览,并在需要时将其从地图上移除。';
@override
String get wizardMapOpsFeature3 => '使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。';
@override
String get wizardToolsTitle => '消息之外的工具';
@override
String get wizardToolsDescription => '这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。';
@override
String get wizardToolsFeature1 => '打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。';
@override
String get wizardToolsFeature2 => '当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。';
@override
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
@override
String get contactInSensors => '在传感器中';
@override
String get contactAddToSensors => '添加到传感器';
@override
String get contactSetPath => '设置路径';
@override
String contactAddedToSensors(String contactName) {
return '已将 $contactName 添加到传感器';
}
@override
String contactFailedToClearRoute(String error) {
return '清除路由失败:$error';
}
@override
String get contactRouteCleared => '路由已清除';
@override
String contactRouteSet(String route) {
return '路由已设置:$route';
}
@override
String contactFailedToSetRoute(String error) {
return '设置路由失败:$error';
}
}

2332
lib/l10n/app_pl.arb Normal file

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

View File

@@ -1,6 +1,5 @@
{
"@@locale": "ru",
"appTitle": "MeshCore SAR",
"messages": "Сообщения",
"contacts": "Контакты",
@@ -8,7 +7,6 @@
"settings": "Настройки",
"connect": "Подключить",
"disconnect": "Отключить",
"scanningForDevices": "Поиск устройств...",
"noDevicesFound": "Устройства не найдены",
"scanAgain": "Повторить поиск",
"tapToConnect": "Нажмите для подключения",
@@ -18,11 +16,8 @@
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
"advertisedAtLocation": "Транслируется в {latitude}, {longitude}",
"failedToAdvertise": "Ошибка трансляции: {error}",
"reconnecting": "Переподключение... ({attempt}/{max})",
"cancelReconnection": "Отменить переподключение",
"mapManagement": "Управление картой",
"general": "Основные",
"theme": "Тема",
"chooseTheme": "Выбрать тему",
@@ -38,33 +33,10 @@
"followSystemTheme": "Следовать системной теме",
"showRxTxIndicators": "Показывать индикаторы RX/TX",
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
"simpleMode": "Простой режим",
"simpleModeDescription": "Скрыть второстепенную информацию в сообщениях и контактах",
"disableMap": "Отключить карту",
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
"language": "Язык",
"chooseLanguage": "Выбрать язык",
"english": "Английский",
"slovenian": "Словенский",
"croatian": "Хорватский",
"german": "Немецкий",
"spanish": "Испанский",
"french": "Французский",
"italian": "Итальянский",
"russian": "Русский",
"locationBroadcasting": "Трансляция местоположения",
"autoLocationTracking": "Авто-отслеживание геолокации",
"automaticallyBroadcastPosition": "Автоматически транслировать обновления позиции",
"configureTracking": "Настроить отслеживание",
"distanceAndTimeThresholds": "Пороги расстояния и времени",
"locationTrackingConfiguration": "Настройка отслеживания геолокации",
"configureWhenLocationBroadcasts": "Настройте условия отправки обновлений геолокации в mesh-сеть",
"minimumDistance": "Минимальное расстояние",
"broadcastAfterMoving": "Транслировать только после перемещения на {distance} м",
"maximumDistance": "Максимальное расстояние",
"alwaysBroadcastAfterMoving": "Всегда транслировать после перемещения на {distance} м",
"minimumTimeInterval": "Минимальный интервал времени",
"alwaysBroadcastEvery": "Всегда транслировать каждые {duration}",
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
@@ -76,8 +48,6 @@
"technologiesUsed": "Используемые технологии:",
"technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища",
"moreInfo": "Подробнее",
"learnMoreAbout": "Узнать больше о MeshCore SAR",
"developer": "Разработчик",
"packageName": "Имя пакета",
"sampleData": "Тестовые данные",
"sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR",
@@ -98,7 +68,6 @@
"delete": "Удалить",
"viewOnMap": "Показать на карте",
"refresh": "Обновить",
"sendDirectMessage": "Отправить",
"resetPath": "Сбросить маршрут (перепроложить)",
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
"copiedToClipboard": "{label} скопировано в буфер обмена",
@@ -132,8 +101,6 @@
"deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?",
"drawing": "Рисунок",
"shareDrawingsCount": "Поделиться {count} рисун{plural}",
"sentDrawingsToRoom": "Отправлено {count} рисун{plural} в {roomName}",
"sharedDrawingsToRoom": "Передано {success}/{total} рисун{plural} в {roomName}",
"showReceivedDrawings": "Показать полученные рисунки",
"showingAllDrawings": "Показаны все рисунки",
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
@@ -141,17 +108,10 @@
"showingSarMarkers": "Маркеры SAR отображаются",
"hidingSarMarkers": "Маркеры SAR скрыты",
"clearAll": "Очистить всё",
"noLocalDrawings": "Нет локальных рисунков для отправки",
"publicChannel": "Публичный канал",
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
"storedPermanently": "Сохранено постоянно в комнате",
"drawingsSentToPublicChannel": "Отправлено {count} рисун{plural} в публичный канал",
"drawingsSharedToPublicChannel": "Передано {success}/{total} рисунков в публичный канал",
"notConnectedToDevice": "Устройство не подключено",
"directMessage": "Личное сообщение",
"directMessageSentTo": "Личное сообщение отправлено {contactName}",
"failedToSend": "Не удалось отправить: {error}",
"directMessageInfo": "Это сообщение будет отправлено напрямую {contactName}. Оно также появится в общей ленте сообщений.",
"typeYourMessage": "Введите сообщение...",
"quickLocationMarker": "Быстрый маркер местоположения",
"markerType": "Тип маркера",
@@ -161,7 +121,6 @@
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
"location": "Местоположение",
"myLocation": "Моё местоположение",
"fromMap": "С карты",
"gettingLocation": "Получение местоположения...",
"locationError": "Ошибка геолокации",
@@ -180,19 +139,12 @@
"failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.",
"direct": "Напрямую",
"flood": "Широковещательно",
"admin": "Администратор",
"loggedIn": "Вход выполнен",
"noGpsData": "Нет данных GPS",
"distance": "Расстояние",
"pingingDirect": "Пинг {name} (напрямую по маршруту)...",
"pingingFlood": "Пинг {name} (широковещательно — нет маршрута)...",
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
"pingSuccessful": "Пинг успешен: {name}{fallback}",
"viaFloodingFallback": " (через широковещательный резерв)",
"pingFailed": "Пинг не удался: {name} — ответ не получен",
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
"removingContact": "Удаление {name}...",
"contactRemoved": "Контакт \"{name}\" удалён",
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
"type": "Тип",
"publicKey": "Публичный ключ",
@@ -207,7 +159,6 @@
"passwordSaved": "Пароль сохранён",
"locationColon": "Местоположение:",
"telemetry": "Телеметрия",
"requestingTelemetry": "Запрос телеметрии от {name}...",
"voltage": "Напряжение",
"battery": "Батарея",
"temperature": "Температура",
@@ -231,7 +182,6 @@
"fire": "Пожар",
"stagingArea": "Место сбора",
"showAll": "Показать все",
"nearbyContacts": "Ближайшие контакты",
"locationUnavailable": "Местоположение недоступно",
"ahead": "впереди",
"degreesRight": "{degrees}° вправо",
@@ -243,83 +193,21 @@
"repeaters": "Ретрансляторы",
"rooms": "Комнаты",
"channels": "Каналы",
"cacheStatistics": "Статистика кэша",
"totalTiles": "Всего тайлов",
"cacheSize": "Размер кэша",
"storeName": "Имя хранилища",
"noCacheStatistics": "Статистика кэша недоступна",
"downloadRegion": "Скачать регион",
"mapLayer": "Слой карты",
"regionBounds": "Границы региона",
"north": "Север",
"south": "Юг",
"east": "Восток",
"west": "Запад",
"zoomLevels": "Уровни масштаба",
"minZoom": "Мин: {zoom}",
"maxZoom": "Макс: {zoom}",
"downloadingDots": "Загрузка...",
"cancelDownload": "Отменить загрузку",
"downloadRegionButton": "Скачать регион",
"downloadNote": "Внимание: большие регионы или высокие уровни масштаба могут потребовать значительного времени и места.",
"cacheManagement": "Управление кэшем",
"clearAllMaps": "Очистить все карты",
"clearMapsConfirmTitle": "Очистить все карты",
"clearMapsConfirmMessage": "Вы уверены, что хотите удалить все загруженные карты? Это действие нельзя отменить.",
"mapDownloadCompleted": "Загрузка карты завершена!",
"cacheClearedSuccessfully": "Кэш успешно очищен!",
"downloadCancelled": "Загрузка отменена",
"startingDownload": "Начало загрузки...",
"downloadingMapTiles": "Загрузка тайлов карты...",
"downloadCompletedSuccessfully": "Загрузка успешно завершена!",
"cancellingDownload": "Отмена загрузки...",
"errorLoadingStats": "Ошибка загрузки статистики: {error}",
"downloadFailed": "Ошибка загрузки: {error}",
"cancelFailed": "Ошибка отмены: {error}",
"clearCacheFailed": "Ошибка очистки кэша: {error}",
"minZoomError": "Мин. масштаб: {error}",
"maxZoomError": "Макс. масштаб: {error}",
"minZoomGreaterThanMax": "Минимальный масштаб должен быть меньше или равен максимальному",
"selectMapLayer": "Выбрать слой карты",
"mapOptions": "Параметры карты",
"showLegend": "Показать легенду",
"displayMarkerTypeCounts": "Отображать счётчики типов маркеров",
"rotateMapWithHeading": "Поворачивать карту по курсу",
"mapFollowsDirection": "Карта следует вашему направлению движения",
"resetMapRotation": "Сбросить поворот",
"resetMapRotationTooltip": "Вернуть карту на север",
"showMapDebugInfo": "Показать отладочную информацию карты",
"displayZoomLevelBounds": "Отображать уровень масштаба и границы",
"fullscreenMode": "Полноэкранный режим",
"hideUiFullMapView": "Скрыть все элементы интерфейса для полного вида карты",
"openStreetMap": "OpenStreetMap",
"openTopoMap": "OpenTopoMap",
"esriSatellite": "ESRI Спутник",
"googleHybrid": "Google Гибрид",
"googleRoadmap": "Google Дороги",
"googleTerrain": "Google Рельеф",
"downloadVisibleArea": "Скачать видимую область",
"initializingMap": "Инициализация карты...",
"dragToPosition": "Перетащите для позиционирования",
"createSarMarker": "Создать маркер SAR",
"compass": "Компас",
"navigationAndContacts": "Навигация и контакты",
"sarAlert": "ТРЕВОГА SAR",
"messageSentToPublicChannel": "Сообщение отправлено в публичный канал",
"pleaseSelectRoomToSendSar": "Пожалуйста, выберите комнату для отправки маркера SAR",
"failedToSendSarMarker": "Не удалось отправить маркер SAR: {error}",
"sarMarkerSentTo": "Маркер SAR отправлен в {roomName}",
"notConnectedCannotSync": "Нет подключения — синхронизация невозможна",
"syncedMessageCount": "Синхронизировано {count} сообщение(й)",
"noNewMessages": "Новых сообщений нет",
"syncFailed": "Синхронизация не удалась: {error}",
"failedToResendMessage": "Не удалось повторно отправить сообщение",
"retryingMessage": "Повторная отправка сообщения...",
"retryFailed": "Повтор не удался: {error}",
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
"messageDeleted": "Сообщение удалено",
"copyText": "Копировать текст",
"saveAsTemplate": "Сохранить как шаблон",
"templateSaved": "Шаблон успешно сохранён",
@@ -329,8 +217,6 @@
"shareLocation": "Поделиться местоположением",
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
"sarLocationShare": "Местоположение SAR",
"locationShared": "Местоположение передано",
"refreshedContacts": "Контакты обновлены",
"justNow": "Только что",
"minutesAgo": "{minutes}м назад",
"hoursAgo": "{hours}ч назад",
@@ -362,39 +248,10 @@
"txPowerDbm": "Мощность TX (дБм)",
"maxPowerDbm": "Макс: {power} дБм",
"you": "Вы",
"offlineVectorMaps": "Офлайн-векторные карты",
"offlineVectorMapsDescription": "Импортируйте офлайн-векторные тайлы карт (формат MBTiles) для использования без интернета",
"importMbtiles": "Импортировать файл MBTiles",
"importMbtilesNote": "Поддерживаются файлы MBTiles с векторными тайлами (формат PBF/MVT). Отлично подходят выгрузки Geofabrik!",
"noMbtilesFiles": "Офлайн-векторные карты не найдены",
"mbtilesImportedSuccessfully": "Файл MBTiles успешно импортирован",
"failedToImportMbtiles": "Не удалось импортировать файл MBTiles",
"deleteMbtilesConfirmTitle": "Удалить офлайн-карту",
"deleteMbtilesConfirmMessage": "Вы уверены, что хотите удалить \"{name}\"? Офлайн-карта будет удалена безвозвратно.",
"mbtilesDeletedSuccessfully": "Офлайн-карта успешно удалена",
"failedToDeleteMbtiles": "Не удалось удалить офлайн-карту",
"importExportCachedTiles": "Импорт/Экспорт кэшированных тайлов",
"importExportDescription": "Резервное копирование, обмен и восстановление загруженных тайлов карт между устройствами",
"exportTilesToFile": "Экспортировать тайлы в файл",
"importTilesFromFile": "Импортировать тайлы из файла",
"selectExportLocation": "Выберите место для экспорта",
"selectImportFile": "Выберите архив тайлов",
"exportingTiles": "Экспорт тайлов...",
"importingTiles": "Импорт тайлов...",
"exportSuccess": "Экспортировано {count} тайлов",
"importSuccess": "Импортировано {count} хранилищ",
"exportFailed": "Ошибка экспорта: {error}",
"importFailed": "Ошибка импорта: {error}",
"exportNote": "Создаёт сжатый архив (.fmtc), которым можно поделиться и импортировать на других устройствах.",
"importNote": "Импортирует тайлы карт из ранее экспортированного архива. Тайлы будут объединены с существующим кэшем.",
"noTilesToExport": "Нет доступных тайлов для экспорта",
"archiveContainsStores": "Архив содержит {count} хранилищ",
"vectorTiles": "Векторные тайлы",
"schema": "Схема",
"unknown": "Неизвестно",
"bounds": "Границы",
"onlineLayers": "Онлайн-слои",
"offlineLayers": "Офлайн-слои",
"locationTrail": "Трек местоположения",
"showTrailOnMap": "Показать трек на карте",
"trailVisible": "Трек отображается на карте",
@@ -407,17 +264,6 @@
"noTrailRecorded": "Трек ещё не записан",
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
"trailControls": "Управление треком",
"exportTrailToGpx": "Экспортировать трек в GPX",
"importTrailFromGpx": "Импортировать трек из GPX",
"trailExportedSuccessfully": "Трек успешно экспортирован!",
"failedToExportTrail": "Не удалось экспортировать трек",
"failedToImportTrail": "Не удалось импортировать трек: {error}",
"importTrail": "Импортировать трек",
"importTrailQuestion": "Импортировать трек из {pointCount} точек?\n\nВы можете заменить текущий трек или просмотреть его рядом.",
"viewAlongside": "Просмотреть рядом",
"replaceCurrent": "Заменить текущий",
"trailImported": "Трек импортирован! ({pointCount} точек)",
"trailReplaced": "Трек заменён! ({pointCount} точек)",
"contactTrails": "Треки контактов",
"showAllContactTrails": "Показать все треки контактов",
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
@@ -496,10 +342,8 @@
"searchRecipients": "Поиск получателей...",
"noContactsFound": "Контакты не найдены",
"noRoomsFound": "Комнаты не найдены",
"noContactsOrRoomsAvailable": "Нет доступных контактов или комнат",
"noRecipientsAvailable": "Нет доступных получателей",
"noChannelsFound": "Каналы не найдены",
"messagesWillBeSentToPublicChannel": "Сообщения будут отправлены в публичный канал",
"newMessage": "Новое сообщение",
"channel": "Канал",
"samplePoliceLead": "Руководитель группы полиции",
@@ -557,18 +401,11 @@
"hideFromMap": "Скрыть с карты",
"lineDrawing": "Линия",
"rectangleDrawing": "Прямоугольник",
"coordinatesCopiedToClipboard": "Координаты скопированы в буфер обмена",
"manualCoordinates": "Ввод координат вручную",
"enterCoordinatesManually": "Ввести координаты вручную",
"latitudeLabel": "Широта",
"longitudeLabel": "Долгота",
"invalidLatitude": "Неверная широта (от -90 до 90)",
"invalidLongitude": "Неверная долгота (от -180 до 180)",
"exampleCoordinates": "Пример: 55.7558, 37.6173",
"drawingShared": "Рисунок на карте",
"drawingHidden": "Рисунок скрыт с карты",
"alreadyShared": "{count, plural, =1{1 уже передан} other{{count} уже передано}}",
"newDrawingsShared": "Передано {count} новых рисунк{plural}",
"shareDrawing": "Поделиться рисунком",
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
"shareToRoom": "Отправить в комнату",
@@ -586,8 +423,6 @@
"updateLater": "Позже",
"cadastralParcels": "Кадастровые участки",
"forestRoads": "Лесные дороги",
"showCadastralParcels": "Показать кадастровые участки",
"showForestRoads": "Показать лесные дороги",
"wmsOverlays": "WMS-наложения",
"hikingTrails": "Туристические маршруты",
"mainRoads": "Главные дороги",
@@ -619,7 +454,6 @@
"deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.",
"channelDeletedSuccessfully": "Канал успешно удалён",
"channelDeletionFailed": "Не удалось удалить канал: {error}",
"allChannelSlotsInUse": "Все слоты каналов заняты (максимум 39 пользовательских каналов)",
"createChannel": "Создать канал",
"wizardBack": "Назад",
"wizardSkip": "Пропустить",
@@ -632,10 +466,6 @@
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
"wizardSimpleModeTitle": "Простой режим",
"wizardSimpleModeDescription": "Впервые работаете с mesh-сетью? Включите простой режим для упрощённого интерфейса с основными функциями.",
"wizardSimpleModeFeature1": "Удобный для новичков интерфейс с основными функциями",
"wizardSimpleModeFeature2": "Переключиться в расширенный режим можно в любое время в настройках",
"wizardChannelTitle": "Каналы",
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
"wizardChannelFeature1": "Публичный канал для общей связи команды",
@@ -681,5 +511,57 @@
"rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен",
"requestingVoice": "Запрос голоса"
"requestingVoice": "Запрос голоса",
"device": "устройство",
"change": "Изменить",
"wizardOverviewDescription": "Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.",
"wizardOverviewFeature1": "Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».",
"wizardOverviewFeature2": "Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.",
"wizardOverviewFeature3": "Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.",
"wizardMessagingTitle": "Сообщения и полевые отчёты",
"wizardMessagingDescription": "Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.",
"wizardMessagingFeature1": "Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.",
"wizardMessagingFeature2": "Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.",
"wizardMessagingFeature3": "Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.",
"wizardConnectDeviceTitle": "Подключить устройство",
"wizardConnectDeviceDescription": "Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.",
"wizardSetupBadge": "Настройка",
"wizardOverviewBadge": "Обзор",
"wizardConnectedToDevice": "Подключено к {deviceName}",
"wizardNoDeviceConnected": "Устройство ещё не подключено",
"wizardSkipForNow": "Пропустить пока",
"wizardDeviceNameLabel": "Имя устройства",
"wizardDeviceNameHelp": "Это имя транслируется другим пользователям MeshCore.",
"wizardConfigRegionLabel": "Регион конфигурации",
"wizardConfigRegionHelp": "Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).",
"wizardPresetNote1": "Убедитесь, что выбранный профиль соответствует местным радиоправилам.",
"wizardPresetNote2": "Список соответствует официальному потоку профилей инструмента MeshCore config.",
"wizardPresetNote3": "Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).",
"wizardSaving": "Сохранение...",
"wizardSaveAndContinue": "Сохранить и продолжить",
"wizardEnterDeviceName": "Введите имя устройства перед продолжением.",
"wizardDeviceSetupSaved": "Сохранено: {deviceName}, профиль {presetName}.",
"wizardNetworkTitle": "Контакты, комнаты и ретрансляторы",
"wizardNetworkDescription": "Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.",
"wizardNetworkFeature1": "Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.",
"wizardNetworkFeature2": "Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.",
"wizardNetworkFeature3": "Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.",
"wizardMapOpsTitle": "Карта, следы и общая геометрия",
"wizardMapOpsDescription": "Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.",
"wizardMapOpsFeature1": "Отслеживайте свою позицию, местоположение команды и траектории движения на карте.",
"wizardMapOpsFeature2": "Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.",
"wizardMapOpsFeature3": "Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.",
"wizardToolsTitle": "Инструменты помимо сообщений",
"wizardToolsDescription": "Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.",
"wizardToolsFeature1": "Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.",
"wizardToolsFeature2": "Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.",
"wizardToolsFeature3": "Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.",
"contactInSensors": "В датчиках",
"contactAddToSensors": "Добавить в датчики",
"contactSetPath": "Задать путь",
"contactAddedToSensors": "{contactName} добавлен в Датчики",
"contactFailedToClearRoute": "Не удалось очистить маршрут: {error}",
"contactRouteCleared": "Маршрут очищен",
"contactRouteSet": "Маршрут задан: {route}",
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}"
}

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_tr.arb Normal file

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_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -19,6 +22,7 @@ import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available
_checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
@@ -324,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea(
left: false,
right: false,

View File

@@ -240,6 +240,5 @@ extension ContactLocalization on Contact {
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
bool get routeSupportsLegacyRawTransport => routeHasPath;
}

View File

@@ -0,0 +1,31 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
);
}
}

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

View File

@@ -12,10 +12,15 @@ import 'image_provider.dart' as ip;
import 'helpers/fragment_ack_wait_registry.dart';
import 'helpers/session_metadata_restore.dart';
import '../services/location_tracking_service.dart';
import '../services/messaging_route_preferences.dart';
import '../services/nearest_router_selector.dart';
import '../services/packet_capture_storage_service.dart';
import '../services/path_history_service.dart';
import '../services/route_hash_preferences.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/ble_packet_log.dart';
import '../models/path_selection.dart';
import '../models/message_reception_details.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/raw_route_probe.dart';
@@ -24,6 +29,32 @@ import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession {
final PathSelection currentSelection;
final ParsedContactRoute? originalRoute;
final bool routerFallbackAttempted;
const _DirectMessageRouteSession({
required this.currentSelection,
required this.originalRoute,
required this.routerFallbackAttempted,
});
_DirectMessageRouteSession copyWith({
PathSelection? currentSelection,
ParsedContactRoute? originalRoute,
bool? routerFallbackAttempted,
}) {
return _DirectMessageRouteSession(
currentSelection: currentSelection ?? this.currentSelection,
originalRoute: originalRoute ?? this.originalRoute,
routerFallbackAttempted:
routerFallbackAttempted ?? this.routerFallbackAttempted,
);
}
}
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
@@ -60,8 +91,30 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
bool _isVoiceAutoGainEnabled = false;
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
bool _isVoiceEchoCancellationEnabled = false;
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
bool _isVoiceNoiseSuppressionEnabled = false;
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false;
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
bool _autoRouteRotationEnabled =
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
bool _clearPathOnMaxRetry =
MessagingRoutePreferences.defaultClearPathOnMaxRetry;
bool get clearPathOnMaxRetry => _clearPathOnMaxRetry;
bool _nearestRelayFallbackEnabled =
MessagingRoutePreferences.defaultNearestRelayFallbackEnabled;
bool get nearestRelayFallbackEnabled => _nearestRelayFallbackEnabled;
final PathHistoryService _pathHistoryService = PathHistoryService();
final NearestRouterSelector _nearestRouterSelector =
const NearestRouterSelector();
final Map<String, _DirectMessageRouteSession> _directMessageRouteSessions =
{};
static const Duration _packetRetryDelay = Duration(milliseconds: 1200);
static const Duration _mediaSwarmResponseWindow = Duration(seconds: 10);
@@ -81,6 +134,9 @@ class AppProvider with ChangeNotifier {
Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
AppProvider({
required this.connectionProvider,
@@ -92,6 +148,9 @@ class AppProvider with ChangeNotifier {
required this.imageProvider,
}) {
_setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking();
_loadMapEnabled();
_loadContactsEnabled();
@@ -100,7 +159,13 @@ class AppProvider with ChangeNotifier {
_loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale();
_loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings();
unawaited(_pathHistoryService.initialize());
_startPacketCapturePersistence();
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
_isInitialized = true;
@@ -385,6 +450,96 @@ class AppProvider with ChangeNotifier {
}
}
Future<void> _loadVoiceAutoGainEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled =
prefs.getBool('voice_auto_gain_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice auto gain setting: $e');
}
}
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
try {
_isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_auto_gain_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice auto gain setting: $e');
}
}
Future<void> _loadVoiceEchoCancellationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled =
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e');
}
}
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
try {
_isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e');
}
}
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled =
prefs.getBool('voice_noise_suppression_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice noise suppression setting: $e');
}
}
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
try {
_isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_noise_suppression_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice noise suppression setting: $e');
}
}
Future<void> _loadMessageFontScale() async {
try {
final prefs = await SharedPreferences.getInstance();
_messageFontScale = (prefs.getDouble('message_font_scale') ?? 1.0).clamp(
0.85,
1.4,
);
notifyListeners();
} catch (e) {
debugPrint('Error loading message font scale setting: $e');
}
}
Future<void> setMessageFontScale(double scale) async {
try {
_messageFontScale = scale.clamp(0.85, 1.4);
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('message_font_scale', _messageFontScale);
notifyListeners();
} catch (e) {
debugPrint('Error saving message font scale setting: $e');
}
}
/// Load auto-add discovered contacts setting from shared preferences.
Future<void> _loadAutoAddDiscoveredContacts() async {
try {
@@ -409,6 +564,50 @@ class AppProvider with ChangeNotifier {
}
}
Future<void> _loadMessagingRouteSettings() async {
try {
_autoRouteRotationEnabled =
await MessagingRoutePreferences.getAutoRouteRotationEnabled();
_clearPathOnMaxRetry =
await MessagingRoutePreferences.getClearPathOnMaxRetry();
_nearestRelayFallbackEnabled =
await MessagingRoutePreferences.getNearestRelayFallbackEnabled();
notifyListeners();
} catch (e) {
debugPrint('Error loading messaging route settings: $e');
}
}
Future<void> toggleAutoRouteRotationEnabled(bool enabled) async {
try {
_autoRouteRotationEnabled = enabled;
await MessagingRoutePreferences.setAutoRouteRotationEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving auto route rotation setting: $e');
}
}
Future<void> toggleClearPathOnMaxRetry(bool enabled) async {
try {
_clearPathOnMaxRetry = enabled;
await MessagingRoutePreferences.setClearPathOnMaxRetry(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving clear path on max retry setting: $e');
}
}
Future<void> toggleNearestRelayFallbackEnabled(bool enabled) async {
try {
_nearestRelayFallbackEnabled = enabled;
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving nearest relay fallback setting: $e');
}
}
/// Initialize location tracking service
Future<void> _initializeLocationTracking() async {
try {
@@ -452,8 +651,20 @@ class AppProvider with ChangeNotifier {
connectionProvider.addListener(_handleConnectionStateChange);
messagesProvider.resolveContactNameCallback =
_resolveContactNameForNotification;
messagesProvider.resolveChannelNameCallback =
channelsProvider.getChannelDisplayName;
messagesProvider.resolveChannelNameCallback = (channelIdx) {
if (channelIdx == 0) {
return 'Public';
}
for (final channel in contactsProvider.channels) {
if (channel.publicKey.length > 1 &&
channel.publicKey[1] == channelIdx) {
return channel.displayName;
}
}
return channelsProvider.getChannelDisplayName(channelIdx);
};
voiceProvider.sendRawPacketCallback =
({
@@ -488,6 +699,7 @@ class AppProvider with ChangeNotifier {
contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
unawaited(_pathHistoryService.recordLearnedPath(contact));
// Broadcast to SSE clients if server is running
connectionProvider.broadcastContactToSseClients(contact);
@@ -500,6 +712,9 @@ class AppProvider with ChangeNotifier {
contacts,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
for (final contact in contacts) {
unawaited(_pathHistoryService.recordLearnedPath(contact));
}
debugPrint('Received ${contacts.length} contacts');
// Broadcast all contacts to SSE clients if server is running
@@ -634,6 +849,18 @@ class AppProvider with ChangeNotifier {
final receptionDetailsSnapshot = _buildReceptionDetailsSnapshot(
enrichedMessage,
);
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null &&
enrichedMessage.isChannelMessage &&
receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) {
unawaited(
_learnPathFromPublicMessage(
contact: senderContact,
pathBytes: receivedPathBytes,
),
);
}
// Check if message is a drawing broadcast
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
@@ -1029,6 +1256,7 @@ class AppProvider with ChangeNotifier {
debugPrint(
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
);
unawaited(_retainAdvertRxPath(publicKey));
// Check if this is an existing contact that might have updated location
final contact = contactsProvider.findContactByKey(publicKey);
if (contact != null) {
@@ -1117,6 +1345,14 @@ class AppProvider with ChangeNotifier {
messagesProvider.handleMessageEcho(messageId, echoCount, snrRaw, rssiDbm);
};
connectionProvider.prepareDirectMessageSendCallback =
({required messageId, required contact, required retryAttempt}) async {
return _prepareDirectMessageSend(
messageId: messageId,
contact: contact,
);
};
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback =
({
@@ -1134,32 +1370,348 @@ class AppProvider with ChangeNotifier {
retryAttempt: retryAttempt,
);
};
messagesProvider.onDirectPathFailedCallback =
({required contact, required failureStreak}) async {
debugPrint(
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(contactPublicKey);
};
messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback(
messageId: messageId,
contact: contact,
message: message,
);
contactsProvider.markPathUnhealthy(contact.publicKey);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
try {
await connectionProvider.resetPath(contact.publicKey);
Future.delayed(const Duration(milliseconds: 150), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(contact.publicKey);
}
});
} catch (e) {
debugPrint(
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
);
}
};
messagesProvider.onFinalDirectMessageFailureCallback =
({required messageId, required contact, required message}) async {
await _handleDirectMessageFinalFailure(
messageId: messageId,
contact: contact,
);
};
messagesProvider.onDirectMessageDeliveredCallback =
({
required messageId,
required contact,
required message,
required roundTripTimeMs,
}) {
_handleDirectMessageDelivered(
messageId: messageId,
contact: contact,
roundTripTimeMs: roundTripTimeMs,
);
};
messagesProvider.onManualRetryPreparedCallback = (messageId) {
_directMessageRouteSessions.remove(messageId);
};
}
Future<Contact> _prepareDirectMessageSend({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
var session = _directMessageRouteSessions[messageId];
if (session == null) {
final selection = await _pathHistoryService.getSelectionForContact(
latestContact,
autoRouteRotationEnabled: _autoRouteRotationEnabled,
);
session = _DirectMessageRouteSession(
currentSelection: selection,
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
_directMessageRouteSessions[messageId] = session;
}
await _applyPathSelection(
latestContact,
session.currentSelection,
messageId: messageId,
routerFallbackAttempted: session.routerFallbackAttempted,
);
return contactsProvider.findContactByKey(contact.publicKey) ??
latestContact;
}
Future<void> _applyPathSelection(
Contact contact,
PathSelection selection, {
required String messageId,
required bool routerFallbackAttempted,
}) async {
final previousRoute = ContactRouteCodec.fromContact(contact);
try {
if (selection.usesFlood) {
contactsProvider.resetContactRouteLocal(contact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(contact.publicKey);
}
} else {
final pathDescriptor =
((selection.hashSize - 1) << 6) | (selection.hopCount & 0x3F);
final signedDescriptor = ContactRouteCodec.toSignedDescriptor(
pathDescriptor,
);
final paddedPathBytes = Uint8List(ContactRouteCodec.maxPathBytes)
..setRange(0, selection.pathBytes.length, selection.pathBytes);
contactsProvider.setContactRouteLocal(
contact.publicKey,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: signedDescriptor,
paddedPathBytes: paddedPathBytes,
);
}
}
} catch (error) {
_restoreRouteLocal(contact.publicKey, previousRoute);
rethrow;
}
messagesProvider.updateMessageRouteSelection(
messageId,
selection,
routerFallbackAttempted: routerFallbackAttempted,
);
}
void _restoreRouteLocal(Uint8List publicKey, ParsedContactRoute? route) {
if (route == null) {
contactsProvider.resetContactRouteLocal(publicKey);
return;
}
contactsProvider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
Future<void> _restoreRouteOnDevice(
Contact contact,
ParsedContactRoute? route,
) async {
_restoreRouteLocal(contact.publicKey, route);
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (route == null) {
await connectionProvider.resetPath(contact.publicKey);
return;
}
await connectionProvider.setContactRoute(
contact,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
}
PathSelection _buildNearestRouterSelection(Contact repeater, int hashSize) {
return PathSelection(
mode: PathSelectionMode.nearestRouter,
pathBytes: Uint8List.fromList(repeater.publicKey.sublist(0, hashSize)),
hopCount: 1,
hashSize: hashSize,
relayName: repeater.advName,
relayKey6: _key6(repeater.publicKey),
);
}
Future<bool> _sendWithFinalNearestRouterFallback({
required String messageId,
required Contact contact,
required Message message,
}) async {
if (!_nearestRelayFallbackEnabled) {
return false;
}
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session =
_directMessageRouteSessions[messageId] ??
_DirectMessageRouteSession(
currentSelection: latestContact.routeHasPath
? PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(latestContact.routePathBytes),
hopCount: latestContact.routeHopCount,
hashSize: latestContact.routeHashSize,
)
: PathSelection.flood(),
originalRoute: ContactRouteCodec.fromContact(latestContact),
routerFallbackAttempted: false,
);
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
final repeater = _nearestRouterSelector.select(
senderPosition: locationTrackingService.currentPosition,
repeaters: contactsProvider.repeaters,
recipient: latestContact,
);
if (repeater == null) {
return false;
}
final routeHashSize = await RouteHashPreferences.getHashSize();
final fallbackSelection = _buildNearestRouterSelection(
repeater,
routeHashSize,
);
_directMessageRouteSessions[messageId] = session.copyWith(
currentSelection: fallbackSelection,
routerFallbackAttempted: true,
);
messagesProvider.updateMessageRouteSelection(
messageId,
fallbackSelection,
routerFallbackAttempted: true,
);
return connectionProvider.sendTextMessage(
contactPublicKey: latestContact.publicKey,
text: message.text,
messageId: messageId,
contact: latestContact,
retryAttempt: message.retryAttempt + 1,
);
}
void _handleDirectMessageDelivered({
required String messageId,
required Contact contact,
required int roundTripTimeMs,
}) {
final session = _directMessageRouteSessions.remove(messageId);
if (session == null) {
return;
}
unawaited(
_pathHistoryService.recordPathResult(
contact.publicKeyHex,
session.currentSelection,
success: true,
roundTripTimeMs: roundTripTimeMs,
),
);
}
Future<void> _handleDirectMessageFinalFailure({
required String messageId,
required Contact contact,
}) async {
final latestContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final session = _directMessageRouteSessions.remove(messageId);
if (session != null) {
await _pathHistoryService.recordPathResult(
latestContact.publicKeyHex,
session.currentSelection,
success: false,
);
if (session.routerFallbackAttempted) {
await _restoreRouteOnDevice(latestContact, session.originalRoute);
}
}
if (_clearPathOnMaxRetry) {
contactsProvider.resetContactRouteLocal(latestContact.publicKey);
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.resetPath(latestContact.publicKey);
Future.delayed(const Duration(milliseconds: 150), () {
if (connectionProvider.deviceInfo.isConnected) {
connectionProvider.getContact(latestContact.publicKey);
}
});
}
}
}
String _key6(Uint8List publicKey) {
final bytes = publicKey.sublist(0, math.min(6, publicKey.length));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _learnPathFromPublicMessage({
required Contact contact,
required List<int> pathBytes,
}) async {
final preferred = await RouteHashPreferences.getHashSize();
final inferredHashSize = _inferReceivedPathHashSize(
pathBytes,
preferredHashSize: preferred,
);
await _pathHistoryService.recordReceivedBytePath(
contact.publicKeyHex,
pathBytes,
inferredHashSize,
);
}
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) {
return;
}
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse(
reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize,
);
contactsProvider.retainReceivedRoute(
publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
}
int _inferReceivedPathHashSize(
List<int> pathBytes, {
required int preferredHashSize,
}) {
final preferred = preferredHashSize;
final candidates = {preferred, 3, 2, 1}.toList();
for (final candidate in candidates) {
if (candidate >= 1 &&
candidate <= 3 &&
pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
/// Initialize the app (load contacts, sync time, etc.)
@@ -1167,6 +1719,8 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering
@@ -1218,7 +1772,9 @@ class AppProvider with ChangeNotifier {
debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
);
final initialMessageCount = await connectionProvider.syncAllMessages();
final initialMessageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
);
@@ -1238,9 +1794,51 @@ class AppProvider with ChangeNotifier {
);
messagesProvider.syncDrawingsWithProvider(drawingProvider);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
Future<void> _syncAfterReconnect({bool started = false}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try {
debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
}
}
@@ -1553,14 +2151,13 @@ class AppProvider with ChangeNotifier {
if (requester == null ||
!requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) {
return;
}
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: availability.encodeBinary(),
),
);
@@ -1630,7 +2227,7 @@ class AppProvider with ChangeNotifier {
for (final peer in peers) {
await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen,
contactPathLen: peer.routeEncodedPathLen,
payload: request.encodeBinary(),
);
}
@@ -1653,7 +2250,6 @@ class AppProvider with ChangeNotifier {
if (responder == null ||
!responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) {
continue;
}
@@ -1745,7 +2341,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
contactPathLen: target.routeEncodedPathLen,
payload: payload,
);
return true;
@@ -1986,9 +2582,6 @@ class AppProvider with ChangeNotifier {
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false;
}
if (!target.routeSupportsLegacyRawTransport) {
return false;
}
if (target.outPath.isEmpty) {
return false;
}
@@ -2021,7 +2614,7 @@ class AppProvider with ChangeNotifier {
);
await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen,
contactPathLen: target.routeEncodedPathLen,
payload: RawRouteProbeRequest(
nonce: nonce,
requesterKey6: requesterKey6,
@@ -2049,7 +2642,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}';
}
return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
}
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -2067,9 +2660,6 @@ class AppProvider with ChangeNotifier {
);
return;
}
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) {
return;
}
@@ -2079,7 +2669,7 @@ class AppProvider with ChangeNotifier {
unawaited(
connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
),
);
@@ -2153,12 +2743,10 @@ class AppProvider with ChangeNotifier {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -2172,13 +2760,42 @@ class AppProvider with ChangeNotifier {
return bestLog;
}
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
final publicKeyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
DecodedLogRxRoute? bestRoute;
var bestDeltaMs = 999999999;
final now = DateTime.now();
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != 0x04) continue;
final senderHash = decoded.originalSenderHashHex;
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestRoute = decoded;
}
}
if (bestDeltaMs > 30000) return null;
return bestRoute;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
@@ -2213,7 +2830,9 @@ class AppProvider with ChangeNotifier {
debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)',
);
final messageCount = await connectionProvider.syncAllMessages();
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages',
);
@@ -2228,6 +2847,8 @@ class AppProvider with ChangeNotifier {
/// Handle connection state changes to manage location tracking
void _handleConnectionStateChange() {
final isConnected = connectionProvider.deviceInfo.isConnected;
final wasConnected = _wasDeviceConnected;
_wasDeviceConnected = isConnected;
final wasTracking = locationTrackingService.isTracking;
// Only stop tracking on disconnect - DON'T start on connect
@@ -2239,6 +2860,33 @@ class AppProvider with ChangeNotifier {
);
_stopLocationTracking();
}
if (!isConnected) {
connectionProvider.clearPendingAutomaticMessageSync();
}
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
}
/// Start location tracking

View File

@@ -175,13 +175,22 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Future<Contact?> Function({
required String messageId,
required Contact contact,
required int retryAttempt,
})?
prepareDirectMessageSendCallback;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
bool _pendingAutomaticMessageSync = false;
ConnectionProvider() {
_wireServiceCallbacks(_bleService);
@@ -326,6 +335,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
);
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) {
_syncRequestedWhileBusy = true;
@@ -480,6 +496,14 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices
Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true;
_scannedDevices.clear();
_error = null;
@@ -553,6 +577,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}',
);
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty
@@ -829,10 +858,26 @@ class ConnectionProvider with ChangeNotifier {
return true;
} catch (e) {
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
if (_isEmptyChannelQueryError(e)) {
debugPrint(
' Treating slot $channelIdx as empty because the device reported it was not found',
);
return true;
}
return false;
}
}
bool _isEmptyChannelQueryError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('not found');
}
bool _isChannelRefreshTimeoutError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('0x1f') && message.contains('timed out');
}
Future<int?> findNextEmptyChannelSlot() async {
if (!_activeService.isConnected) {
throw Exception('Not connected to device');
@@ -843,6 +888,35 @@ class ConnectionProvider with ChangeNotifier {
// maxChannels from device info, or default to 40
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
// Match meshcore-open: trust the current synced channel list first and
// pick the first missing slot rather than probing every slot on-device.
if (getChannelInfo != null) {
final usedIndices = <int>{};
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel == null) {
continue;
}
final channelName = (channel as dynamic).name as String?;
if (channelName != null && channelName.isNotEmpty) {
usedIndices.add(i);
}
}
for (int i = 1; i < maxChannels; i++) {
if (!usedIndices.contains(i)) {
debugPrint(' ✅ Found empty slot from synced channels: $i');
return i;
}
}
debugPrint(
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
);
}
// Check each slot starting from 1 (skip 0 = public channel)
for (int i = 1; i < maxChannels; i++) {
@@ -939,10 +1013,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
} else {
// Find next empty slot for new channel
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final emptySlot = await findNextEmptyChannelSlot();
if (emptySlot == null) {
throw Exception(
'All channel slots are in use (maximum 39 custom channels)',
'All channel slots are in use (maximum $maxCustomChannels custom channels)',
);
}
slotIdx = emptySlot;
@@ -963,23 +1039,33 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Secret converted to 16-byte key using MD5');
}
// Send CMD_SET_CHANNEL to radio
await _activeService.setChannel(
channelIdx: slotIdx,
channelName: channelName,
secret: secretBytes,
);
// Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
// reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
try {
await _activeService.setChannel(
channelIdx: slotIdx,
channelName: channelName,
secret: secretBytes,
);
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint(
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
);
// Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100));
// Refresh channels to update UI
// The channel info will be received via onChannelInfoReceived callback
await _activeService.getChannel(slotIdx);
} catch (e) {
_error = 'Failed to create channel: $e';
debugPrint('❌ [Provider] Channel creation failed: $e');
@@ -1073,7 +1159,7 @@ class ConnectionProvider with ChangeNotifier {
///
/// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
@@ -1087,6 +1173,18 @@ class ConnectionProvider with ChangeNotifier {
return false;
}
var effectiveContact = contact;
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
if (messageId != null &&
effectiveContact != null &&
prepareDirectMessageSendCallback != null) {
effectiveContact = await prepareDirectMessageSendCallback!(
messageId: messageId,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
}
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
// Rate limit at 7 to stay under the limit
if (_messageDeliveryTracker.shouldRateLimit) {
@@ -1111,33 +1209,33 @@ class ConnectionProvider with ChangeNotifier {
try {
// Log path status and retry info
if (contact != null) {
if (effectiveContact != null) {
if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
);
} else {
debugPrint(
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
);
}
debugPrint(' Type: ${contact.type.displayName}');
debugPrint(' Path status: ${contact.routeSummary}');
if (contact.routeHasPath) {
debugPrint(' Type: ${effectiveContact.type.displayName}');
debugPrint(' Path status: ${effectiveContact.routeSummary}');
if (effectiveContact.routeHasPath) {
debugPrint(
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
);
} else {
debugPrint(' ⚠️ No path available - will use flood mode');
}
} else if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
);
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1146,7 +1244,7 @@ class ConnectionProvider with ChangeNotifier {
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
debugPrint(
@@ -1191,7 +1289,7 @@ class ConnectionProvider with ChangeNotifier {
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1805,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier {
}
/// Sync all waiting messages from device
Future<int> syncAllMessages() async {
Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
);
return 0;
}
if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
@@ -1825,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier {
int totalCount = 0;
try {
_pendingAutomaticMessageSync = false;
_isSyncingMessages = true;
do {
_syncRequestedWhileBusy = false;
@@ -1916,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier {
}
}
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
void clearPendingAutomaticMessageSync() {
_pendingAutomaticMessageSync = false;
}
/// Login to a room or repeater
///
/// Sends login request with password. Results will be delivered via

View File

@@ -1,6 +1,8 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
@@ -10,8 +12,15 @@ import '../utils/key_comparison.dart';
class PendingAdvert {
final Uint8List publicKey;
final DateTime receivedAt;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
const PendingAdvert({required this.publicKey, required this.receivedAt});
const PendingAdvert({
required this.publicKey,
required this.receivedAt,
this.signedEncodedPathLen,
this.paddedPathBytes,
});
String get publicKeyHex =>
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
@@ -21,17 +30,36 @@ class PendingAdvert {
return prefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
}
PendingAdvert copyWith({Uint8List? publicKey, DateTime? receivedAt}) {
PendingAdvert copyWith({
Uint8List? publicKey,
DateTime? receivedAt,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
}) {
return PendingAdvert(
publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt,
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
);
}
}
class _RetainedRoute {
final int signedEncodedPathLen;
final Uint8List paddedPathBytes;
const _RetainedRoute({
required this.signedEncodedPathLen,
required this.paddedPathBytes,
});
}
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
@@ -53,6 +81,7 @@ class ContactsProvider with ChangeNotifier {
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
);
final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -66,8 +95,11 @@ class ContactsProvider with ChangeNotifier {
}
_isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts',
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
// Ensure public channel exists after loading
@@ -97,6 +129,7 @@ class ContactsProvider with ChangeNotifier {
final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey,
);
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -110,8 +143,11 @@ class ContactsProvider with ChangeNotifier {
}
_isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts',
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
// Ensure public channel exists after loading
@@ -182,10 +218,97 @@ class ContactsProvider with ChangeNotifier {
}
List<Contact> get contacts => _contacts.values.toList();
List<SavedContactGroup> get savedContactGroups =>
List<SavedContactGroup>.from(_savedContactGroups)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
List<PendingAdvert> get pendingAdverts =>
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
.toList();
}
bool hasSavedGroupForFilter(String sectionKey, String query) {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty) {
return false;
}
return _savedContactGroups.any(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
}
Future<void> addSavedGroupForFilter(
String sectionKey,
String query, {
String? label,
}) async {
final normalizedQuery = _normalizeGroupQuery(query);
if (normalizedQuery.isEmpty ||
hasSavedGroupForFilter(sectionKey, normalizedQuery)) {
return;
}
_savedContactGroups.add(
SavedContactGroup(
id: '${sectionKey}_${DateTime.now().microsecondsSinceEpoch}',
sectionKey: sectionKey,
label: (label ?? query).trim(),
query: query.trim(),
createdAt: DateTime.now(),
),
);
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupById(String id) async {
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere((group) => group.id == id);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> removeSavedGroupForFilter(
String sectionKey,
String query,
) async {
final normalizedQuery = _normalizeGroupQuery(query);
final beforeCount = _savedContactGroups.length;
_savedContactGroups.removeWhere(
(group) =>
group.sectionKey == sectionKey &&
_normalizeGroupQuery(group.query) == normalizedQuery,
);
if (_savedContactGroups.length == beforeCount) {
return;
}
await _persistSavedGroups();
notifyListeners();
}
Future<void> _persistSavedGroups() async {
try {
await _storageService.saveContactGroups(_savedContactGroups);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
}
}
String _normalizeGroupQuery(String query) => query.trim().toLowerCase();
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
@@ -338,8 +461,42 @@ class ContactsProvider with ChangeNotifier {
required Contact incomingContact,
Contact? existingContact,
}) {
final retainedRoute = _retainedRouteForContact(
keyHex: incomingContact.publicKeyHex,
incomingContact: incomingContact,
existingContact: existingContact,
);
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact?.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final inferredFallbackLocation = _inferFirstHopFallbackLocation(
incomingContact: incomingContact,
existingContact: existingContact,
retainedRoute: retainedRoute,
mergedTelemetry: mergedTelemetry,
);
final inferredFallbackAdvLat = inferredFallbackLocation != null
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.latitude)
: null;
final inferredFallbackAdvLon = inferredFallbackLocation != null
? _coordinateToAdvertMicrodegrees(inferredFallbackLocation.longitude)
: null;
if (existingContact == null) {
var newContact = incomingContact.copyWith(isNew: true);
var newContact = incomingContact.copyWith(
isNew: true,
telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
advLat: incomingContact.advertLocation != null
? incomingContact.advLat
: inferredFallbackAdvLat ?? incomingContact.advLat,
advLon: incomingContact.advertLocation != null
? incomingContact.advLon
: inferredFallbackAdvLon ?? incomingContact.advLon,
);
if (incomingContact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000,
@@ -352,27 +509,26 @@ class ContactsProvider with ChangeNotifier {
return newContact;
}
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final incomingAdvertLocation = incomingContact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
var updatedContact = incomingContact.copyWith(
isNew: existingContact.isNew,
isNew: false,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
outPath: retainedRoute?.paddedPathBytes ?? incomingContact.outPath,
advLat: incomingAdvertLocation != null
? incomingContact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: incomingContact.advLat,
: inferredFallbackAdvLat ?? incomingContact.advLat,
advLon: incomingAdvertLocation != null
? incomingContact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: incomingContact.advLon,
: inferredFallbackAdvLon ?? incomingContact.advLon,
);
if (incomingAdvertLocation != null) {
@@ -388,6 +544,145 @@ class ContactsProvider with ChangeNotifier {
return updatedContact;
}
_RetainedRoute? _retainedRouteForContact({
required String keyHex,
required Contact incomingContact,
required Contact? existingContact,
}) {
if (incomingContact.routeHasPath) {
return null;
}
final pendingAdvert = _pendingAdverts[keyHex];
final pendingPathBytes = pendingAdvert?.paddedPathBytes;
final pendingPathLen = pendingAdvert?.signedEncodedPathLen;
if (pendingPathLen != null &&
pendingPathBytes != null &&
pendingPathBytes.isNotEmpty) {
return _RetainedRoute(
signedEncodedPathLen: pendingPathLen,
paddedPathBytes: Uint8List.fromList(pendingPathBytes),
);
}
if (existingContact != null && existingContact.routeHasPath) {
return _RetainedRoute(
signedEncodedPathLen: existingContact.outPathLen,
paddedPathBytes: Uint8List.fromList(existingContact.outPath),
);
}
return null;
}
LatLng? _inferFirstHopFallbackLocation({
required Contact incomingContact,
required Contact? existingContact,
required _RetainedRoute? retainedRoute,
required ContactTelemetry? mergedTelemetry,
}) {
if (_getValidGpsOrNull(mergedTelemetry?.gpsLocation) != null) {
return null;
}
if (incomingContact.advertLocation != null ||
existingContact?.advertLocation != null) {
return null;
}
final routeBytes =
retainedRoute?.paddedPathBytes ??
(incomingContact.routeHasPath
? incomingContact.routePathBytes
: existingContact?.routeHasPath == true
? existingContact!.routePathBytes
: null);
final routeHashSize = retainedRoute != null
? ((ContactRouteCodec.toUnsignedDescriptor(
retainedRoute.signedEncodedPathLen,
) >>
6) +
1)
: incomingContact.routeHasPath
? incomingContact.routeHashSize
: existingContact?.routeHasPath == true
? existingContact!.routeHashSize
: 0;
if (routeBytes == null ||
routeHashSize <= 0 ||
routeBytes.length < routeHashSize) {
return null;
}
final lastHopHex = routeBytes
.sublist(routeBytes.length - routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final repeaterCandidates = _contacts.values.where((candidate) {
if (!candidate.isRepeater ||
candidate.publicKeyHex == incomingContact.publicKeyHex) {
return false;
}
final location = candidate.displayLocation;
return location != null && candidate.publicKeyHex.startsWith(lastHopHex);
}).toList()..sort((a, b) => b.lastAdvert.compareTo(a.lastAdvert));
if (repeaterCandidates.isEmpty) {
return null;
}
final repeaterLocation = repeaterCandidates.first.displayLocation;
if (repeaterLocation == null) {
return null;
}
final bearingDegrees = _stableFallbackBearingDegrees(incomingContact);
return _offsetFromLocation(
repeaterLocation,
distanceMeters: _firstHopFallbackOffsetMeters,
bearingDegrees: bearingDegrees,
);
}
double _stableFallbackBearingDegrees(Contact contact) {
if (contact.publicKey.length < 2) {
return 90.0;
}
final seed = (contact.publicKey[0] << 8) | contact.publicKey[1];
return (seed % 360).toDouble();
}
LatLng _offsetFromLocation(
LatLng origin, {
required double distanceMeters,
required double bearingDegrees,
}) {
const earthRadiusMeters = 6371000.0;
final angularDistance = distanceMeters / earthRadiusMeters;
final bearingRadians = bearingDegrees * 3.1415926535897932 / 180.0;
final lat1 = origin.latitude * 3.1415926535897932 / 180.0;
final lon1 = origin.longitude * 3.1415926535897932 / 180.0;
final sinLat1 = sin(lat1);
final cosLat1 = cos(lat1);
final sinAngularDistance = sin(angularDistance);
final cosAngularDistance = cos(angularDistance);
final lat2 = asin(
sinLat1 * cosAngularDistance +
cosLat1 * sinAngularDistance * cos(bearingRadians),
);
final lon2 =
lon1 +
atan2(
sin(bearingRadians) * sinAngularDistance * cosLat1,
cosAngularDistance - sinLat1 * sin(lat2),
);
return LatLng(
lat2 * 180.0 / 3.1415926535897932,
((lon2 * 180.0 / 3.1415926535897932 + 540.0) % 360.0) - 180.0,
);
}
/// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
@@ -646,20 +941,74 @@ class ContactsProvider with ChangeNotifier {
Uint8List publicKey, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
LatLng? inferredFallbackLocation,
}) {
final contact = findContactByKey(publicKey);
if (contact == null) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
var updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
if (inferredFallbackLocation != null) {
updatedContact = updatedContact
.copyWith(
advLat: _coordinateToAdvertMicrodegrees(
inferredFallbackLocation.latitude,
),
advLon: _coordinateToAdvertMicrodegrees(
inferredFallbackLocation.longitude,
),
)
.addAdvertLocation(inferredFallbackLocation, DateTime.now());
}
_contacts[contact.publicKeyHex] = updatedContact;
_persistContacts();
notifyListeners();
}
void retainReceivedRoute(
Uint8List publicKey, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
Uint8List? devicePublicKey,
}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final contact = _contacts[keyHex];
if (contact != null) {
_contacts[keyHex] = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
_persistContacts();
notifyListeners();
return;
}
final now = DateTime.now();
final existing = _pendingAdverts[keyHex];
_pendingAdverts[keyHex] =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
))
.copyWith(
receivedAt: now,
signedEncodedPathLen: signedEncodedPathLen,
paddedPathBytes: Uint8List.fromList(paddedPathBytes),
);
notifyListeners();
}
void resetContactRouteLocal(Uint8List publicKey) {
final contact = findContactByKey(publicKey);
if (contact == null) {

View File

@@ -5,15 +5,13 @@ import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// This helper class centralizes retry logic for direct messages.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
@@ -21,10 +19,10 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
static const int maxRetryAttempts = 4;
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -32,13 +30,12 @@ class MessageRetryManager {
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
}
return _timeouts[attempt];
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
@@ -65,43 +62,8 @@ class MessageRetryManager {
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) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.routeHasPath;
}
/// 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;
return message.retryAttempt < maxRetryAttempts;
}
/// Track a retry attempt for a message

View File

@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
);
return false;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
try {
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;

View File

@@ -5,6 +5,8 @@ import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import '../models/path_selection.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
@@ -27,6 +29,7 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -59,6 +62,8 @@ class MessagesProvider with ChangeNotifier {
// Navigation state for message highlighting/scrolling
String? _targetMessageId;
String? _targetDestinationType;
String? _targetRecipientPublicKeyHex;
// Helper function to compare Uint8List for equality
bool _listEquals(Uint8List a, Uint8List b) {
@@ -81,6 +86,26 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({
required String messageId,
required Contact contact,
required Message message,
})?
onFinalRouterFallbackCallback;
Future<void> Function({
required String messageId,
required Contact contact,
required Message message,
})?
onFinalDirectMessageFailureCallback;
void Function({
required String messageId,
required Contact contact,
required Message message,
required int roundTripTimeMs,
})?
onDirectMessageDeliveredCallback;
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
String Function(int channelIdx)? resolveChannelNameCallback;
@@ -126,6 +151,34 @@ class MessagesProvider with ChangeNotifier {
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
_messageTransferDetails[messageId];
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
_messageRouteMetadata[messageId];
void updateMessageRouteSelection(
String messageId,
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
selection,
routerFallbackAttempted: routerFallbackAttempted,
);
final index = _messages.indexWhere((message) => message.id == messageId);
if (index != -1) {
final nextPathLen = selection.hopCount > 0
? selection.hopCount
: _messages[index].pathLen;
_messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen,
);
}
_persistMessages();
notifyListeners();
}
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -137,16 +190,99 @@ class MessagesProvider with ChangeNotifier {
notifyListeners();
}
String? get targetDestinationType => _targetDestinationType;
String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex;
void navigateToDestination(String type, {String? recipientPublicKeyHex}) {
_targetDestinationType = type;
_targetRecipientPublicKeyHex = recipientPublicKeyHex;
notifyListeners();
}
/// Clear message navigation state
void clearMessageNavigation() {
_targetMessageId = null;
}
void clearDestinationNavigation() {
_targetDestinationType = null;
_targetRecipientPublicKeyHex = null;
}
/// Get count of unread messages (excluding sent messages and system messages)
int get unreadCount => _messages
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
.length;
int getMessageCountForDestination(Contact contact) => _messages
.where((message) => _isMessageForDestination(message, contact))
.length;
DateTime? getLastActivityForDestination(Contact contact) {
DateTime? latest;
for (final message in _messages) {
if (!_isMessageForDestination(message, contact)) {
continue;
}
if (latest == null || message.sentAt.isAfter(latest)) {
latest = message.sentAt;
}
}
return latest;
}
int getUnreadCountForDestination(Contact contact) => _messages
.where(
(message) =>
_isMessageForDestination(message, contact) &&
!message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage,
)
.length;
bool _isMessageForDestination(Message message, Contact contact) {
if (message.isSystemMessage) return false;
if (contact.isChannel) {
final channelIdx = contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return message.isChannelMessage &&
(message.channelIdx ?? 0) == channelIdx;
}
if (!message.isContactMessage || contact.publicKey.length < 6) {
return false;
}
final selectedPrefix = contact.publicKey.sublist(0, 6);
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
_matchesPrefix(message.recipientPublicKey!, selectedPrefix)) {
return true;
}
if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6 &&
_matchesPrefix(message.senderPublicKeyPrefix!, selectedPrefix)) {
return true;
}
return false;
}
bool _matchesPrefix(Uint8List key, Uint8List prefix) {
for (var i = 0; i < 6; i++) {
if (key[i] != prefix[i]) return false;
}
return true;
}
/// Initialize and load persisted messages
Future<void> initialize() async {
if (_isInitialized) return;
@@ -160,6 +296,8 @@ class MessagesProvider with ChangeNotifier {
.loadMessageReceptionDetails();
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
@@ -169,6 +307,9 @@ class MessagesProvider with ChangeNotifier {
_messageTransferDetails
..clear()
..addAll(storedTransferDetails);
_messageRouteMetadata
..clear()
..addAll(storedRouteMetadata);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -629,9 +770,17 @@ class MessagesProvider with ChangeNotifier {
final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) {
if (idx == 0 && resolved == 'Public') {
return _localizations?.publicChannel ?? resolved;
}
return resolved;
}
return idx == 0 ? 'Public' : 'Channel $idx';
if (idx == 0) {
return _localizations?.publicChannel ?? 'Public';
}
return _localizations != null
? '${_localizations!.channel} $idx'
: 'Channel $idx';
}
String _buildNotificationMessageText(
@@ -645,14 +794,14 @@ class MessagesProvider with ChangeNotifier {
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary;
return summary;
}
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary;
return summary;
}
if (!isChannelMessage && message.recipientPublicKey != null) {
@@ -665,10 +814,6 @@ class MessagesProvider with ChangeNotifier {
}
}
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text;
}
@@ -688,6 +833,7 @@ class MessagesProvider with ChangeNotifier {
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
);
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
@@ -768,6 +914,55 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Mark unread messages for a specific destination as read.
void markDestinationAsRead({
required String destinationType,
Contact? contact,
}) {
if (destinationType == 'all') {
markAllAsRead();
return;
}
bool hasChanges = false;
for (int i = 0; i < _messages.length; i++) {
final message = _messages[i];
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
continue;
}
final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact),
'contact' ||
'room' => contact != null && _isMessageForDestination(message, contact),
_ => false,
};
if (!matchesDestination) {
continue;
}
_messages[i] = message.copyWith(isRead: true);
hasChanges = true;
}
if (hasChanges) {
_persistMessages();
notifyListeners();
}
}
bool _isChannelMessageForContact(Message message, Contact? contact) {
if (!message.isChannelMessage) {
return false;
}
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return (message.channelIdx ?? 0) == selectedChannelIdx;
}
/// Mark a specific message as read
void markAsRead(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
@@ -806,6 +1001,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
_messageRouteMetadata.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -837,6 +1033,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_persistMessages();
notifyListeners();
}
@@ -854,6 +1051,7 @@ class MessagesProvider with ChangeNotifier {
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_persistMessages();
notifyListeners();
}
@@ -1549,6 +1747,12 @@ class MessagesProvider with ChangeNotifier {
final deliveredContact = _messageContactMap[message.id];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
onDirectMessageDeliveredCallback?.call(
messageId: message.id,
contact: deliveredContact,
message: updatedMessage,
roundTripTimeMs: roundTripTimeMs,
);
}
debugPrint(
@@ -1592,6 +1796,12 @@ class MessagesProvider with ChangeNotifier {
final deliveredContact = _messageContactMap[historicalMessageId];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
onDirectMessageDeliveredCallback?.call(
messageId: historicalMessageId,
contact: deliveredContact,
message: _messages[historicalIndex],
roundTripTimeMs: roundTripTimeMs,
);
}
_persistMessages();
notifyListeners();
@@ -1677,29 +1887,29 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail
final routeMetadata = _messageRouteMetadata[messageId];
final routerFallbackAttempted =
routeMetadata?.routerFallbackAttempted ?? false;
// Decision tree for retry/final-router-fallback/fail
if (contact != null && _retryManager.canRetry(message, contact)) {
// RETRY: Contact has path and retry attempts < 3
_scheduleRetry(messageId, message, contact);
} else if (contact != null &&
_retryManager.shouldUseFloodFallback(message, contact)) {
// FLOOD FALLBACK: After 3 retries failed, try flood once
_sendWithFloodMode(messageId, message, contact);
} else if (contact != null && !routerFallbackAttempted) {
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
} else {
// PERMANENTLY FAILED: No retry possible
_markAsPermanentlyFailed(messageId, message);
}
}
/// Schedule a retry with progressive timeout
/// Schedule a retry with exponential backoff.
void _scheduleRetry(String messageId, Message message, Contact contact) {
final nextAttempt = message.retryAttempt + 1;
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
debugPrint(
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
);
debugPrint(' Timeout: ${timeout}ms');
debugPrint(' Delay: ${delayMs}ms');
// Update message with new retry attempt
final index = _messages.indexWhere((m) => m.id == messageId);
@@ -1720,10 +1930,10 @@ class MessagesProvider with ChangeNotifier {
// Track retry
_retryManager.trackRetry(messageId, nextAttempt);
notifyListeners(); // Update UI to show "Retrying (X/3)..."
notifyListeners();
// Schedule actual retry after delay
Timer(Duration(milliseconds: timeout), () async {
Timer(Duration(milliseconds: delayMs), () async {
debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
);
@@ -1759,49 +1969,46 @@ class MessagesProvider with ChangeNotifier {
}
}
/// Send message with flood mode as last resort
Future<void> _sendWithFloodMode(
Future<void> _sendWithFinalRouterFallback(
String messageId,
Message message,
Contact contact,
) async {
debugPrint(
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
);
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
usedFloodFallback: true,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Cancel old timeout timer
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
notifyListeners();
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
if (onFinalRouterFallbackCallback == null) {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
return;
}
final queued = await onFinalRouterFallbackCallback!(
messageId: messageId,
contact: contact,
message: _messages[index],
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1830,19 +2037,15 @@ class MessagesProvider with ChangeNotifier {
_retryManager.clearRetry(messageId);
final failedContact = _messageContactMap[messageId];
if (failedContact != null && failedContact.routeHasPath) {
final failureStreak = _retryManager.recordPathFailure(failedContact);
debugPrint(
' Path failure streak for ${failedContact.advName}: $failureStreak',
if (failedContact != null &&
onFinalDirectMessageFailureCallback != null) {
unawaited(
onFinalDirectMessageFailureCallback!(
messageId: messageId,
contact: failedContact,
message: _messages[index],
),
);
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
unawaited(
onDirectPathFailedCallback!(
contact: failedContact,
failureStreak: failureStreak,
),
);
}
}
_persistMessages();
@@ -1870,6 +2073,8 @@ class MessagesProvider with ChangeNotifier {
}
_clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId);
_messages[index] = Message(
id: message.id,

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
);
try {
final pcm = await _codec.decodePackets(session.packets, session.mode);
final decodedPcm = await _codec.decodePackets(
session.packets,
session.mode,
);
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId;
notifyListeners();
await _player.play(pcm);
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) {
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
notifyListeners();
}
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
return pcm;
}
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,15 +14,19 @@ import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map }
@@ -55,6 +59,7 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
@@ -90,6 +95,8 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) {
@@ -192,7 +199,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
@@ -213,10 +219,17 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
Future<void> _loadRxTxPreference() async {
@@ -228,6 +241,14 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
@@ -378,10 +399,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count,
required bool isActive,
required Color activeColor,
bool compact = false,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
@@ -390,15 +415,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
width: compact ? 6 : 7,
height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
SizedBox(width: compact ? 5 : 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600,
color: color,
),
@@ -598,56 +623,127 @@ class _HomeScreenState extends State<HomeScreen>
),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const SpectrumScanScreen(),
),
);
});
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[];
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
),
);
// Reload preference when returning from settings
_loadRxTxPreference();
});
},
),
],
);
});
},
),
);
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.router_outlined),
SizedBox(width: 8),
Text('Repeaters Map'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () async {
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (context) => SettingsScreen(
onThemeChanged: widget.onThemeChanged,
onLocaleChanged: widget.onLocaleChanged,
currentTheme: widget.currentTheme,
currentLocale: widget.currentLocale,
),
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
);
return items;
},
),
],
),
@@ -666,6 +762,7 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
case _HomeTab.sensors:
return const SensorsTab();
@@ -715,7 +812,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
text: 'Chat',
);
case _HomeTab.contacts:
return Tab(
@@ -999,6 +1096,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () {
Navigator.push(
context,
@@ -1014,17 +1112,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity,
)
: Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(22),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
@@ -1032,13 +1132,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
compact: true,
),
const SizedBox(height: 6),
const SizedBox(height: 4),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
compact: true,
),
],
),

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
import '../widgets/map/detailed_compass_dialog.dart';
import '../widgets/map/drawing_layer.dart';
import '../widgets/map/drawing_toolbar.dart';
import '../widgets/map/location_trail_layer.dart';
import '../widgets/map/trail_controls.dart';
import '../widgets/map/map_message_overlay.dart';
@@ -2131,6 +2132,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
right: 16,
child: Column(
children: [
const DrawingToolbar(),
const SizedBox(height: 8),
// Hide other buttons when in drawing mode
if (!drawingProvider.isDrawing) ...[
// Current Location - always center to GPS

View File

@@ -71,7 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false;
bool _isSendingVoice = false;
static const int _maxVoicePackets = 10;
static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2;
@@ -88,15 +88,13 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount);
// Load saved message destination
_loadSavedDestination();
_loadVoiceBitrate();
// Mark all messages as read when tab is opened
_loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest();
});
}
Future<void> _loadVoiceBitrate() async {
Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
@@ -128,11 +126,20 @@ class _MessagesTabState extends State<MessagesTab> {
void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId;
final targetDestinationType = messagesProvider.targetDestinationType;
if (targetMessageId != null) {
_scrollToMessage(targetMessageId);
messagesProvider.clearMessageNavigation();
}
if (targetDestinationType != null) {
_applyPendingDestination(
type: targetDestinationType,
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
);
messagesProvider.clearDestinationNavigation();
}
}
void _scrollToMessage(String messageId) {
@@ -258,6 +265,7 @@ class _MessagesTabState extends State<MessagesTab> {
/// Show recipient selector bottom sheet
void _showRecipientSelector() {
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Filter contacts by type
final contacts = contactsProvider.contacts
@@ -278,6 +286,13 @@ class _MessagesTabState extends State<MessagesTab> {
contacts: contacts,
rooms: rooms,
channels: channels,
unreadCount: messagesProvider.unreadCount,
unreadCountsByPublicKey: {
for (final contact in [...contacts, ...rooms, ...channels])
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
contact,
),
},
currentDestinationType: _destinationType,
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
onSelect: _onRecipientSelected,
@@ -292,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient;
});
_markCurrentDestinationAsRead();
_enforceMessageByteLimit();
// Save to preferences
@@ -304,9 +321,107 @@ class _MessagesTabState extends State<MessagesTab> {
if (!mounted) return;
}
Future<void> _applyPendingDestination({
required String type,
String? recipientPublicKeyHex,
}) async {
Contact? recipient;
if (recipientPublicKeyHex != null) {
final contactsProvider = context.read<ContactsProvider>();
recipient = contactsProvider.contacts.where((contact) {
return contact.publicKeyHex == recipientPublicKeyHex;
}).firstOrNull;
}
await _onRecipientSelected(type, recipient);
if (!mounted) return;
_focusNode.requestFocus();
}
void _insertReplyMention(String displayName) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_enforceMessageByteLimit();
}
Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String destinationType;
Contact? recipient;
if (message.isChannelMessage) {
final channelIdx = message.channelIdx ?? 0;
destinationType = MessageDestinationPreferences.destinationTypeChannel;
if (channelIdx != 0) {
recipient = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == channelIdx;
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
}
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
} else {
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) {
ToastLogger.error(context, l10n.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
}
_focusNode.requestFocus();
}
/// Get icon for current destination type
IconData _getDestinationIcon() {
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return Icons.all_inbox;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
return Icons.public;
} else if (_destinationType ==
@@ -318,6 +433,9 @@ class _MessagesTabState extends State<MessagesTab> {
}
String _getDestinationLabel() {
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
return AppLocalizations.of(context)!.showAll;
}
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
@@ -343,6 +461,12 @@ class _MessagesTabState extends State<MessagesTab> {
return;
}
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
if (!mounted) return;
ToastLogger.error(context, 'Select a channel, contact, or room first');
return;
}
try {
// Check destination type and send accordingly
if (_destinationType ==
@@ -375,6 +499,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return;
} catch (e) {
@@ -692,7 +817,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately.
// Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) {
setState(() {
@@ -731,11 +856,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate,
);
final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
milliseconds: _activeVoiceMode!.packetDurationMs,
);
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms',
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
);
_recordedChunks.clear();
@@ -744,9 +870,13 @@ class _MessagesTabState extends State<MessagesTab> {
try {
final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
@@ -757,7 +887,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
);
setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) {
if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice();
}
@@ -774,6 +904,12 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async {
if (!_isRecording) return;
final trimSilenceEnabled = context
@@ -788,9 +924,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear();
debugPrint(
@@ -979,6 +1120,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0);
}
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks;
@@ -1008,16 +1156,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0;
for (final sample in chunk) {
final absSample = sample.abs();
if (absSample > peak) peak = absSample;
sumSquares += sample * sample;
if (absSample > peak) {
peak = absSample;
}
}
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
return peak;
}
bool _isPublicChannelSelected() {
@@ -1107,6 +1267,15 @@ class _MessagesTabState extends State<MessagesTab> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
});
},
),
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
@@ -1174,6 +1343,116 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
void _showFilteredMessageSearch() {
final messagesProvider = context.read<MessagesProvider>();
final scopedMessages = _getFilteredMessages(messagesProvider);
final searchController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setModalState) {
final query = searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? scopedMessages
: scopedMessages.where((message) {
return message.text.toLowerCase().contains(query) ||
(message.senderName?.toLowerCase().contains(query) ??
false);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Search in current filter',
prefixIcon: const Icon(Icons.search),
suffixIcon: searchController.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
setModalState(() {});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
onChanged: (_) => setModalState(() {}),
),
),
Expanded(
child: matches.isEmpty
? Center(
child: Text(
query.isEmpty
? 'No messages in this filter'
: 'No matches in this filter',
),
)
: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) =>
const Divider(height: 1),
itemBuilder: (context, index) {
final message = matches[index];
final title =
message.senderName?.trim().isNotEmpty ==
true
? message.senderName!
: message.isSentMessage
? 'You'
: _getDestinationLabel();
return ListTile(
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
message.text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () {
Navigator.pop(sheetContext);
_scrollToMessage(message.id);
},
);
},
),
),
],
),
),
),
);
},
);
},
).whenComplete(searchController.dispose);
}
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
@@ -1432,6 +1711,13 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1439,17 +1725,18 @@ class _MessagesTabState extends State<MessagesTab> {
List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel.
if (_destinationType ==
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
filteredMessages = allMessages;
} else if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (selectedChannelIdx == 0) {
// Public channel view keeps showing all messages (current app behavior).
filteredMessages = allMessages;
} else {
filteredMessages = allMessages
.where((message) => message.channelIdx == selectedChannelIdx)
.toList();
}
filteredMessages = allMessages
.where(
(message) =>
message.isChannelMessage &&
(message.channelIdx ?? 0) == selectedChannelIdx,
)
.toList();
}
// If a contact or room is selected, filter by recipient/sender prefixes.
else if ((_destinationType ==
@@ -1458,6 +1745,10 @@ class _MessagesTabState extends State<MessagesTab> {
MessageDestinationPreferences.destinationTypeRoom) &&
_selectedRecipient != null) {
filteredMessages = allMessages.where((message) {
if (!message.isContactMessage) {
return false;
}
// Include messages sent TO this recipient
if (message.recipientPublicKey != null &&
message.recipientPublicKey!.length >= 6 &&
@@ -1538,6 +1829,7 @@ class _MessagesTabState extends State<MessagesTab> {
onRefresh: _handleRefresh,
onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap,
onReplyToMessage: _replyToMessage,
),
),
Positioned(

View File

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

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

@@ -371,6 +371,7 @@ class _SensorCard extends StatelessWidget {
final l10n = AppLocalizations.of(context)!;
final telemetry = contact?.telemetry;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final metrics = contact == null || telemetry == null
? const <_MetricCardData>[]
: _buildMetricCards(l10n, telemetry, contact!);
@@ -378,16 +379,23 @@ class _SensorCard extends StatelessWidget {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.14),
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart';
@@ -15,12 +16,13 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
@@ -70,6 +72,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10;
bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService();
@@ -81,10 +87,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
_loadVoicePreferences();
_loadRouteHashSizePreference();
_loadImagePreferences();
_loadFastLocationSettings();
_loadDeveloperMode();
_loadOnlineTraceCacheStatus();
}
@override
@@ -114,12 +122,62 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _loadDeveloperMode() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = false;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
return;
}
final nextTapCount = _versionTapCount + 1;
if (nextTapCount >= 3) {
await DeveloperModeService.setEnabled(true);
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = true;
_versionTapCount = 0;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
return;
}
if (!mounted) return;
setState(() {
_versionTapCount = nextTapCount;
});
}
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadVoiceBitratePreference() async {
Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
@@ -424,7 +482,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async {
// Only on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -783,6 +841,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 {
final selected = await showDialog<int>(
context: context,
@@ -929,6 +1047,45 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
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(
leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text(
@@ -938,6 +1095,63 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: const Text('Delete all stored message history'),
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('Voice'),
@@ -949,6 +1163,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
@@ -995,6 +1214,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
@@ -1106,8 +1362,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
@@ -1197,6 +1455,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
? '${_packageInfo!.version} (${_packageInfo!.buildNumber})'
: 'Loading...',
),
onTap: _handleVersionTap,
),
ListTile(
leading: const Icon(Icons.badge),
@@ -1212,7 +1471,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(),
),
]),
if (Platform.isAndroid)
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding(
padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon(
@@ -1511,6 +1770,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled,
required bool compressorEnabled,
required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled,
}) {
final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1523,6 +1785,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1530,7 +1795,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs;
final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration(
@@ -1565,7 +1830,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Bitrate: $bitrate bps',
'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
@@ -1612,6 +1877,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
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(
children: [
Expanded(
@@ -1624,7 +1914,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/4',
'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall,
),
],

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../services/wizard_preferences.dart';
import '../widgets/connection_dialog.dart';
/// Welcome wizard screen to introduce new users to the app
class WelcomeWizardScreen extends StatefulWidget {
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController();
final TextEditingController _deviceNameController = TextEditingController();
int _currentPage = 0;
static const int _totalPages = 5;
bool _isApplyingDeviceSetup = false;
static const int _totalPages = 6;
static const List<_RadioPreset> _radioPresets = [
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
_RadioPreset(
id: 'australia',
label: 'Australia',
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
frequencyKhz: 915800,
bandwidth: 8,
spreadingFactor: 10,
codingRate: 5,
),
_RadioPreset(
id: 'australia_narrow',
label: 'Australia (Narrow)',
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
frequencyKhz: 916575,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 8,
),
_RadioPreset(
id: 'australia_sa_wa',
label: 'Australia: SA, WA',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'australia_qld',
label: 'Australia: QLD',
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 923125,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'eu_uk_narrow',
label: 'EU/UK (Narrow)',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'eu_uk_deprecated',
label: 'EU/UK (Deprecated)',
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 869525,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'czech_republic_narrow',
label: 'Czech Republic (Narrow)',
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 869432,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'eu_433_long_range',
label: 'EU 433MHz (Long Range)',
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 433650,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand',
label: 'New Zealand',
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 917375,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
_RadioPreset(
id: 'new_zealand_narrow',
label: 'New Zealand (Narrow)',
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 917375,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'portugal_433',
label: 'Portugal 433',
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
frequencyKhz: 433375,
bandwidth: 6,
spreadingFactor: 9,
codingRate: 6,
),
_RadioPreset(
id: 'portugal_868',
label: 'Portugal 868',
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 6,
),
_RadioPreset(
id: 'switzerland',
label: 'Switzerland',
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
frequencyKhz: 869618,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 8,
),
_RadioPreset(
id: 'usa_canada_recommended',
label: 'USA/Canada (Recommended)',
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
frequencyKhz: 910525,
bandwidth: 6,
spreadingFactor: 7,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_narrow',
label: 'Vietnam (Narrow)',
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
frequencyKhz: 920250,
bandwidth: 6,
spreadingFactor: 8,
codingRate: 5,
),
_RadioPreset(
id: 'vietnam_deprecated',
label: 'Vietnam (Deprecated)',
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
frequencyKhz: 920250,
bandwidth: 8,
spreadingFactor: 11,
codingRate: 5,
),
];
_RadioPreset _selectedPreset = _radioPresets[4];
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_deviceNameController.text =
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
}
@override
void dispose() {
_pageController.dispose();
_deviceNameController.dispose();
super.dispose();
}
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
if (_currentPage == 1) {
_handleDeviceSetupAction();
} else if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
}
Future<void> _openConnectionDialog() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const ConnectionDialog(),
);
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) {
await connectionProvider.refreshDeviceInfo();
}
if (!mounted) return;
final deviceInfo = connectionProvider.deviceInfo;
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
if (fetchedName.isNotEmpty) {
setState(() {
_deviceNameController.text = fetchedName;
});
} else {
setState(() {});
}
}
Future<void> _handleDeviceSetupAction() async {
final l10n = AppLocalizations.of(context)!;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
if (!deviceInfo.isConnected) {
await _openConnectionDialog();
return;
}
final trimmedName = _deviceNameController.text.trim();
if (trimmedName.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
return;
}
setState(() {
_isApplyingDeviceSetup = true;
});
try {
await connectionProvider.setAdvertName(trimmedName);
await connectionProvider.setRadioParams(
frequency: _selectedPreset.frequencyKhz,
bandwidth: _selectedPreset.bandwidth,
spreadingFactor: _selectedPreset.spreadingFactor,
codingRate: _selectedPreset.codingRate,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
),
backgroundColor: Colors.green,
),
);
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToSave(e.toString())),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isApplyingDeviceSetup = false;
});
}
}
}
void _skipDeviceSetupStep() {
if (_isApplyingDeviceSetup) return;
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
children: [
// Top bar with skip button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -96,8 +352,10 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildWelcomePage(context, l10n, colorScheme),
_buildConnectDevicePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme),
@@ -108,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Page indicators
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
@@ -130,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
// Next/Get Started button
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _nextPage,
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16.0),
shape: RoundedRectangleBorder(
@@ -142,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
),
),
child: Text(
_currentPage < _totalPages - 1
_currentPage == 1
? _deviceSetupButtonLabel(context)
: _currentPage < _totalPages - 1
? l10n.wizardNext
: l10n.wizardGetStarted,
style: const TextStyle(fontSize: 16),
@@ -165,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
icon: Icons.waving_hand,
iconColor: Colors.orange,
title: l10n.wizardWelcomeTitle,
description: l10n.wizardWelcomeDescription,
description: l10n.wizardOverviewDescription,
features: [
_FeatureItem(
icon: Icons.chat_bubble_outline,
text: l10n.wizardOverviewFeature1,
),
_FeatureItem(
icon: Icons.emergency_share,
text: l10n.wizardOverviewFeature2,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardOverviewFeature3,
),
],
colorScheme: colorScheme,
);
}
@@ -176,33 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.bluetooth_searching,
icon: Icons.forum_rounded,
iconColor: Colors.blue,
title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription,
title: l10n.wizardMessagingTitle,
description: l10n.wizardMessagingDescription,
features: [
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
],
colorScheme: colorScheme,
);
}
Widget _buildConnectDevicePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
final connectionProvider = context.watch<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final isConnected = deviceInfo.isConnected;
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600,
maxHeight: constraints.maxHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: Icons.bluetooth_connected,
iconColor: Colors.green,
title: l10n.wizardConnectDeviceTitle,
description: l10n.wizardConnectDeviceDescription,
badge: l10n.wizardSetupBadge,
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
isConnected
? Icons.check_circle
: Icons.bluetooth_searching,
color: isConnected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
isConnected
? l10n.wizardConnectedToDevice(
connectedName ?? l10n.device,
)
: l10n.wizardNoDeviceConnected,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
),
),
TextButton(
onPressed: _openConnectionDialog,
child: Text(
isConnected ? l10n.change : l10n.connect,
),
),
],
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _skipDeviceSetupStep,
child: Text(l10n.wizardSkipForNow),
),
),
],
),
),
const SizedBox(height: 12),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _deviceNameController,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: l10n.wizardDeviceNameLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardDeviceNameHelp,
),
),
const SizedBox(height: 12),
DropdownButtonFormField<_RadioPreset>(
initialValue: _selectedPreset,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.wizardConfigRegionLabel,
border: OutlineInputBorder(),
helperText: l10n.wizardConfigRegionHelp,
),
items: _radioPresets
.map(
(preset) => DropdownMenuItem<_RadioPreset>(
value: preset,
child: Text(
preset.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (preset) {
if (preset == null) return;
setState(() {
_selectedPreset = preset;
});
},
),
],
),
),
const SizedBox(height: 16),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_selectedPreset.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
_selectedPreset.summary,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
...[
_FeatureItem(
icon: Icons.rule,
text: l10n.wizardPresetNote1,
),
_FeatureItem(
icon: Icons.settings_input_antenna,
text: l10n.wizardPresetNote2,
),
_FeatureItem(
icon: Icons.public,
text: l10n.wizardPresetNote3,
),
].map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: _buildFeatureRow(
context,
colorScheme,
feature,
iconSize: 20,
gap: 12,
),
),
),
],
),
),
],
),
),
),
),
);
}
String _deviceSetupButtonLabel(BuildContext context) {
final isConnected = context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected;
if (_isApplyingDeviceSetup) {
return AppLocalizations.of(context)!.wizardSaving;
}
final l10n = AppLocalizations.of(context)!;
return isConnected
? l10n.wizardSaveAndContinue
: l10n.wizardConnectDeviceTitle;
}
Widget _buildChannelPage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.campaign,
icon: Icons.groups_rounded,
iconColor: Colors.purple,
title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription,
title: l10n.wizardNetworkTitle,
description: l10n.wizardNetworkDescription,
features: [
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
_FeatureItem(
icon: Icons.person_add_alt_1,
text: l10n.wizardNetworkFeature1,
),
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
],
colorScheme: colorScheme,
);
@@ -214,17 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.people,
icon: Icons.map_rounded,
iconColor: Colors.teal,
title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription,
title: l10n.wizardMapOpsTitle,
description: l10n.wizardMapOpsDescription,
features: [
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
_FeatureItem(
icon: Icons.battery_std,
text: l10n.wizardContactsFeature3,
),
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
],
colorScheme: colorScheme,
);
@@ -236,18 +699,17 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.map,
icon: Icons.tune_rounded,
iconColor: Colors.red,
title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription,
title: l10n.wizardToolsTitle,
description: l10n.wizardToolsDescription,
features: [
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
_FeatureItem(
icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2,
icon: Icons.analytics_outlined,
text: l10n.wizardToolsFeature3,
),
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
],
colorScheme: colorScheme,
);
@@ -261,69 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
List<_FeatureItem>? features,
required ColorScheme colorScheme,
}) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 20),
// Icon
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 80, color: iconColor),
final l10n = AppLocalizations.of(context)!;
return LayoutBuilder(
builder: (context, constraints) => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 560,
maxHeight: constraints.maxHeight,
),
const SizedBox(height: 32),
// Title
Text(
title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 32),
// Features list
...features.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(feature.icon, color: colorScheme.primary, size: 24),
const SizedBox(width: 16),
Expanded(
child: Text(
feature.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildPageHeader(
context: context,
colorScheme: colorScheme,
icon: icon,
iconColor: iconColor,
title: title,
description: description,
badge: l10n.wizardOverviewBadge,
),
),
if (features != null && features.isNotEmpty) ...[
const SizedBox(height: 18),
_buildContentCard(
colorScheme: colorScheme,
child: Column(
children: features
.map(
(feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: _buildFeatureRow(
context,
colorScheme,
feature,
),
),
)
.toList(),
),
),
],
],
),
],
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildPageHeader({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required Color iconColor,
required String title,
required String description,
required String badge,
}) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 56, color: iconColor),
),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
description,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.72),
height: 1.4,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildContentCard({
required ColorScheme colorScheme,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: child,
);
}
Widget _buildFeatureRow(
BuildContext context,
ColorScheme colorScheme,
_FeatureItem feature, {
double iconSize = 22,
double gap = 14,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
SizedBox(width: gap),
Expanded(
child: Text(
feature.text,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
),
),
],
);
}
}
class _FeatureItem {
@@ -332,3 +874,23 @@ class _FeatureItem {
_FeatureItem({required this.icon, required this.text});
}
class _RadioPreset {
final String id;
final String label;
final String summary;
final int frequencyKhz;
final int bandwidth;
final int spreadingFactor;
final int codingRate;
const _RadioPreset({
required this.id,
required this.label,
required this.summary,
required this.frequencyKhz,
required this.bandwidth,
required this.spreadingFactor,
required this.codingRate,
});
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -58,6 +59,16 @@ class BackgroundLocationService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
@@ -68,8 +79,7 @@ class BackgroundLocationService {
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
@@ -171,4 +181,22 @@ class BackgroundLocationService {
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/foundation.dart';
/// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms
@@ -25,7 +24,7 @@ class BuildInfoService {
}
// Only Android has the platform channel implementation
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown';
return _cachedCommitHash!;

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,12 +2,14 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
/// Save contacts to persistent storage
@@ -90,6 +92,40 @@ class ContactStorageService {
}
}
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
try {
@@ -203,4 +239,31 @@ class ContactStorageService {
return null;
}
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
}

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

View File

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

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -148,6 +149,10 @@ class LocationTrackingService {
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
if (Platform.isIOS) {
return permission == LocationPermission.always;
}
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
@@ -181,6 +186,16 @@ class LocationTrackingService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
onError?.call(
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
);
return false;
}
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
@@ -209,9 +224,9 @@ class LocationTrackingService {
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
timeLimit: timeLimit,
distanceFilter: 0,
),
);
@@ -255,13 +270,35 @@ class LocationTrackingService {
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter.toInt(),
),
);
}
LocationSettings _buildLocationSettings({
int distanceFilter = 10,
Duration? timeLimit,
}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
timeLimit: timeLimit,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
timeLimit: timeLimit,
);
}
// ============================================================================
// Tracking Control
// ============================================================================

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class MeshMapNode {
final int type;
@@ -33,25 +35,36 @@ class MeshMapNode {
class MeshMapNodesService {
static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2);
static const Duration traceCacheTtl = Duration(minutes: 10);
static const int repeaterType = 1;
static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt;
static Future<void>? _ongoingSync;
static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false,
Duration cacheTtl = _cacheTtl,
bool allowNetwork = true,
http.Client? client,
}) async {
final now = DateTime.now();
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (!forceRefresh &&
_cachedNodes != null &&
cached.isNotEmpty &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
return cached;
}
final response = await http
if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -69,8 +82,133 @@ class MeshMapNodesService {
)
.toList();
_cachedNodes = nodes;
_cachedAt = now;
await _storeCache(nodes, cachedAt: now);
return nodes;
}
static Future<List<MeshMapNode>> loadCachedNodes({
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
final cachedJson = prefs.getString(_cacheKey);
if (cachedAtMs == null || cachedJson == null) {
_cachedNodes = null;
_cachedAt = null;
return const [];
}
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
if (now.difference(cachedAt) >= cacheTtl) {
_cachedNodes = null;
_cachedAt = cachedAt;
return const [];
}
final decoded = jsonDecode(cachedJson) as List<dynamic>;
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.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';
/// Destination types
static const String destinationTypeAll = 'all';
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
final publicKey = prefs.getString(_recipientPublicKeyKey);
return {
'type': type,
'publicKey': ?publicKey,
};
return {'type': type, 'publicKey': ?publicKey};
}
/// Save the selected destination
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination(
String type, {
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeAll:
return 'All';
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:

View File

@@ -5,6 +5,7 @@ import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
@@ -16,6 +17,10 @@ class MessageStorageService {
'stored_message_reception_details';
static const String _messageTransferDetailsKey =
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
@@ -24,12 +29,21 @@ class MessageStorageService {
Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
}) async {
try {
final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Convert messages to JSON and embed path bytes as a fallback so they
// survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
@@ -44,6 +58,7 @@ class MessageStorageService {
final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{};
final routeMetadataJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
@@ -59,6 +74,11 @@ class MessageStorageService {
transferJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageRouteMetadata.entries) {
if (retainedMessageIds.contains(entry.key)) {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
@@ -71,6 +91,10 @@ class MessageStorageService {
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
await prefs.setString(
_messageRouteMetadataKey,
jsonEncode(routeMetadataJson),
);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -115,24 +139,36 @@ class MessageStorageService {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
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;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -170,6 +206,32 @@ class MessageStorageService {
}
}
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageRouteMetadata>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
result[entry.key] = MessageRouteMetadata.fromJson(value);
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
return const {};
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
@@ -206,6 +268,7 @@ class MessageStorageService {
await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
@@ -237,7 +300,10 @@ class MessageStorageService {
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -297,9 +363,67 @@ class MessageStorageService {
},
)
.toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
};
}
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() 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
Message? _messageFromJson(Map<String, dynamic> json) {
try {

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

@@ -402,10 +402,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage
? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}'
: 'Channel: ${channelName ?? "Public"}')
? '${localizations.channel}: $resolvedChannelName'
: 'Channel: $resolvedChannelName')
: (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName');
@@ -550,7 +553,7 @@ class NotificationService {
final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion'
'${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details

View File

@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
File? _file;
Future<File> _resolveFile() async {
if (kIsWeb) {
throw UnsupportedError('Packet capture file storage is not supported on web');
}
if (_file != null) return _file!;
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/$_fileName');
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
Future<void> appendLogs(List<BlePacketLog> logs) async {
if (logs.isEmpty) return;
if (kIsWeb) return;
try {
final file = await _resolveFile();
final sink = file.openWrite(mode: FileMode.append);
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
}
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
if (kIsWeb) return const [];
try {
final file = await _resolveFile();
if (!await file.exists()) return const [];
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
}
Future<int> count() async {
if (kIsWeb) return 0;
try {
final file = await _resolveFile();
if (!await file.exists()) return 0;
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
}
Future<void> clear() async {
if (kIsWeb) return;
try {
final file = await _resolveFile();
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

@@ -0,0 +1,21 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/voice_message_parser.dart';
class VoiceCodecPreferences {
static const String _codecKey = 'voice_codec';
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
static Future<VoiceCodecKind> getCodec() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_codecKey);
return VoiceCodecKind.values.firstWhere(
(codec) => codec.name == raw,
orElse: () => defaultCodec,
);
}
static Future<void> setCodec(VoiceCodecKind codec) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_codecKey, codec.name);
}
}

View File

@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.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) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
case VoicePacketMode.mode3200:
return Codec2Mode.mode3200;
case VoicePacketMode.mode1600:
return Codec2Mode.mode1600;
case VoicePacketMode.mode1400:
return Codec2Mode.mode1400;
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) {
_ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
}
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
@@ -59,24 +60,14 @@ class VoiceCodecService {
VoicePacketMode mode,
) async {
_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>[];
for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
all.add(Int16List(mode.samplesPerPacket));
} else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
all.add(decoded);
all.add(await decode(pkt.codec2Data, mode));
}
}
final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total);
var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data';
import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode {
mode3200(0),
mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond {
switch (this) {
case mode3200: return 400;
case mode700c: return 100;
case mode1200: return 150;
case mode1300: return 175;
case mode1400: return 175;
case mode1600: return 200;
case mode2400: return 300;
case mode3200:
return 400;
case mode700c:
return 100;
case mode1200:
return 150;
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) {
switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
case VoicePacketMode.mode3200:
return Codec2Mode.mode3200;
case VoicePacketMode.mode1600:
return Codec2Mode.mode1600;
case VoicePacketMode.mode1400:
return Codec2Mode.mode1400;
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(
List<VoicePacket?> packets,
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: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].
class VoicePlayerService {
final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
});
}
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
Future<void> play(Int16List pcmSamples) async {
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop();
_position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
_duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now();
_events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes);

View File

@@ -4,7 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.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
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
@@ -27,12 +27,16 @@ class VoiceRecorderService {
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
/// [enableCompressor] normalizes speech dynamics 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({
Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000,
bool enableBandPassFilter = true,
bool enableCompressor = true,
bool enableLimiter = true,
bool enableAutoGain = false,
bool enableEchoCancellation = false,
bool enableNoiseSuppression = false,
}) {
if (_isRecording) {
throw StateError('VoiceRecorderService: already recording');
@@ -43,39 +47,59 @@ class VoiceRecorderService {
_startRecording(
chunkDuration,
sampleRateHz: sampleRateHz,
enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
enableAutoGain: enableAutoGain,
enableEchoCancellation: enableEchoCancellation,
enableNoiseSuppression: enableNoiseSuppression,
);
return _controller!.stream;
}
Future<void> _startRecording(
Duration chunkDuration, {
required int sampleRateHz,
required bool enableBandPassFilter,
required bool enableCompressor,
required bool enableLimiter,
required bool enableAutoGain,
required bool enableEchoCancellation,
required bool enableNoiseSuppression,
}) async {
final config = const RecordConfig(
final useBandPassFilter = enableBandPassFilter;
final useCompressor = enableCompressor;
final useLimiter = enableLimiter;
final config = RecordConfig(
encoder: AudioEncoder.pcm16bits,
sampleRate: 8000,
sampleRate: sampleRateHz,
numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API
autoGain: enableAutoGain,
echoCancel: enableEchoCancellation,
noiseSuppress: enableNoiseSuppression,
);
try {
final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter(
sampleRate: 8000,
sampleRate: sampleRateHz,
lowCutHz: 250.0,
highCutHz: 3400.0,
);
final dynamics = _VoiceDynamicsProcessor(
sampleRate: 8000,
enableCompressor: enableCompressor,
enableLimiter: enableLimiter,
sampleRate: sampleRateHz,
thresholdDb: -18.0,
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>[];
_sub = stream.listen(
@@ -85,9 +109,7 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(dynamics.process(filtered));
}
},
@@ -95,9 +117,7 @@ class VoiceRecorderService {
if (buffer.isNotEmpty) {
final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded));
final filtered = enableBandPassFilter
? voiceFilter.process(pcm)
: pcm;
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
_controller?.add(dynamics.process(filtered));
}
_controller?.close();
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
_VoiceDynamicsProcessor({
required int sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
required bool enableCompressor,
required bool enableLimiter,
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
thresholdDb: thresholdDb,
ratio: ratio,
attackMs: attackMs,
releaseMs: releaseMs,
makeupGainDb: makeupGainDb,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);

View File

@@ -1,4 +1,6 @@
class AvatarLabelHelper {
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
static String buildLabel(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
return '#${_take(hashBody, 2)}'.toUpperCase();
}
final parts = trimmed
.split(RegExp(r'[\s_-]+'))
final parts = _alnumChunks
.allMatches(trimmed)
.map((match) => match.group(0)!)
.where((part) => part.isNotEmpty)
.toList();
if (parts.isEmpty) {
return '?';
}
if (parts.length >= 2) {
final first = _take(parts[0], 1);
final second = _take(parts[1], 1);
return '$first$second'.toUpperCase();
}
return _take(trimmed, 2).toUpperCase();
return _take(parts.first, 2).toUpperCase();
}
static String _take(String value, int count) {

View File

@@ -0,0 +1,116 @@
import '../models/contact.dart';
class InferredContactGroup {
final String key;
final String label;
final List<Contact> contacts;
const InferredContactGroup({
required this.key,
required this.label,
required this.contacts,
});
DateTime get latestSeen => contacts.first.lastSeenTime;
}
class ContactListItem {
final Contact? contact;
final InferredContactGroup? group;
const ContactListItem._({this.contact, this.group});
const ContactListItem.contact(Contact contact) : this._(contact: contact);
const ContactListItem.group(InferredContactGroup group)
: this._(group: group);
bool get isGroup => group != null;
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
}
class ContactGrouping {
static final RegExp _prefixedNamePattern = RegExp(
r'^([A-Za-z0-9]{2,})([-_/:])',
);
static List<Contact> sortByLastSeen(List<Contact> contacts) {
return List<Contact>.from(contacts)
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
}
static List<ContactListItem> buildItems(
List<Contact> contacts, {
int minGroupSize = 4,
}) {
final sortedContacts = sortByLastSeen(contacts);
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
}
static List<ContactListItem> buildItemsFromSorted(
List<Contact> sortedContacts, {
int minGroupSize = 4,
}) {
final groupedContacts = <String, List<Contact>>{};
final groupLabels = <String, String>{};
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
if (prefix == null) continue;
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
}
final eligibleGroups = <String, InferredContactGroup>{};
for (final entry in groupedContacts.entries) {
if (entry.value.length < minGroupSize) continue;
eligibleGroups[entry.key] = InferredContactGroup(
key: entry.key,
label: groupLabels[entry.key] ?? entry.key,
contacts: entry.value,
);
}
final emittedGroups = <String>{};
final items = <ContactListItem>[];
for (final contact in sortedContacts) {
final prefix = _extractPrefix(contact.displayName);
final group = prefix == null ? null : eligibleGroups[prefix.key];
if (group == null) {
items.add(ContactListItem.contact(contact));
continue;
}
if (emittedGroups.add(group.key)) {
items.add(ContactListItem.group(group));
}
}
return items;
}
static _GroupPrefix? _extractPrefix(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
final match = _prefixedNamePattern.firstMatch(trimmed);
if (match == null) return null;
final rawPrefix = match.group(1);
final separator = match.group(2);
if (rawPrefix == null || separator == null) return null;
return _GroupPrefix(
key: rawPrefix.toUpperCase(),
label: '$rawPrefix$separator',
);
}
}
class _GroupPrefix {
final String key;
final String label;
const _GroupPrefix({required this.key, required this.label});
}

View File

@@ -4,38 +4,55 @@ import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
final int pathDescriptor;
final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({
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 {
final int hash;
final String hashHex;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({
required this.hash,
required this.hashHex,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
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 {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
@@ -52,44 +69,147 @@ class LogRxRouteDecoder {
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
final pathDescriptor = rawPacketData[index++];
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(
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(
int hash, {
String hashHex, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
double? ownLatitude,
double? ownLongitude,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
);
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
@@ -98,12 +218,15 @@ class LogRxRouteDecoder {
}
if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
);
}
@@ -119,11 +242,19 @@ class LogRxRouteDecoder {
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
hashHex: normalizedHashHex,
label: label,
isOwnNode: false,
isUniqueMatch: false,
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,12 +1,17 @@
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../models/message.dart';
import '../l10n/app_localizations.dart';
import '../providers/messages_provider.dart';
/// Extension for Message to provide localized delivery status
extension MessageLocalization on Message {
/// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
@@ -26,29 +31,51 @@ extension MessageLocalization on Message {
case MessageDeliveryStatus.sending:
if (isContactMessage) {
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;
case MessageDeliveryStatus.sent:
return l10n.sent;
return routeMetadata == null
? l10n.sent
: '${l10n.sent}${routeMetadata.modeLabel}';
case MessageDeliveryStatus.delivered:
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) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3';
final routeSuffix = routeMetadata != null
? '${routeMetadata.modeLabel}'
: '';
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
}
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:
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:
return '';
}

View File

@@ -0,0 +1,132 @@
import 'package:latlong2/latlong.dart';
import '../services/mesh_map_nodes_service.dart';
class ResolvedTraceNode {
final MeshMapNode? node;
final int matchCount;
final bool usedOnlineFallback;
const ResolvedTraceNode({
required this.node,
required this.matchCount,
required this.usedOnlineFallback,
});
bool get hasMatch => node != null;
bool get isAmbiguous => matchCount > 1;
String? get matchSummary {
if (matchCount <= 1) return null;
final source = usedOnlineFallback ? 'online' : 'local';
return '$matchCount $source matches';
}
}
class TraceNodeResolver {
static const Distance _distance = Distance();
const TraceNodeResolver._();
static ResolvedTraceNode resolveBest({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required String? prefixHex,
LatLng? referenceA,
LatLng? referenceB,
String? preferredPrefix,
}) {
if (prefixHex == null || prefixHex.isEmpty) {
return const ResolvedTraceNode(
node: null,
matchCount: 0,
usedOnlineFallback: false,
);
}
final allMatches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList();
if (allMatches.isEmpty) {
return const ResolvedTraceNode(
node: null,
matchCount: 0,
usedOnlineFallback: false,
);
}
final localMatches = allMatches
.where((node) => localPublicKeys.contains(node.publicKey))
.toList();
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
final usedOnlineFallback = localMatches.isEmpty;
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
final preferredMatches = pool
.where((node) => node.publicKey.startsWith(preferredPrefix))
.toList();
if (preferredMatches.isNotEmpty) {
pool = preferredMatches;
}
}
pool.sort((a, b) {
final distanceCompare =
_scoreNode(
a,
referenceA: referenceA,
referenceB: referenceB,
).compareTo(
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
);
if (distanceCompare != 0) return distanceCompare;
return b.updatedAtMs.compareTo(a.updatedAtMs);
});
return ResolvedTraceNode(
node: pool.first,
matchCount: pool.length,
usedOnlineFallback: usedOnlineFallback,
);
}
static double _scoreNode(
MeshMapNode node, {
LatLng? referenceA,
LatLng? referenceB,
}) {
final point = LatLng(node.latitude, node.longitude);
if (referenceA != null && referenceB != null) {
return _distanceToSegmentMeters(point, referenceA, referenceB);
}
if (referenceA != null) {
return _distance.as(LengthUnit.Meter, point, referenceA);
}
if (referenceB != null) {
return _distance.as(LengthUnit.Meter, point, referenceB);
}
return double.maxFinite;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

@@ -11,20 +11,41 @@ const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
/// Identifies which Codec2 mode was used for a voice packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C'),
mode1200(1, '1200'),
mode2400(2, '2400'),
mode1300(3, '1300'),
mode1400(4, '1400'),
mode1600(5, '1600'),
mode3200(6, '3200');
enum VoiceCodecKind {
codec2(0, 'Codec2');
const VoicePacketMode(this.id, this.label);
const VoiceCodecKind(this.id, this.label);
final int id;
final String label;
}
/// Identifies which voice codec/mode was used for a packet.
/// Matches the modeId byte in the text/binary packet header.
enum VoicePacketMode {
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
const VoicePacketMode(
this.id,
this.label,
this.codec,
this.sampleRateHz,
this.bytesPerSecond,
this.packetDurationMs,
);
final int id;
final String label;
final VoiceCodecKind codec;
final int sampleRateHz;
final int bytesPerSecond;
final int packetDurationMs;
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
(m) => m.id == id,
@@ -153,23 +174,21 @@ class VoicePacket {
/// Estimated audio duration of this packet in milliseconds.
int get durationMs {
// bytesPerSecond for each mode
final bps = switch (mode) {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
};
if (bps == 0) return 0;
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
try {
final bytesPerSecond = voiceModeBytesPerSecond(mode);
if (bytesPerSecond <= 0) return 0;
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
} catch (_) {
// Be permissive with stale or malformed persisted voice metadata.
return 0;
}
}
@override
String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
final suffix = total > 0
? ' ${mode.label} [$index/${total - 1}]'
: ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
}
}
@@ -244,10 +263,10 @@ class VoiceEnvelope {
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
VoicePacketMode.mode700c => 100,
VoicePacketMode.mode1200 => 150,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode1300 => 175,
VoicePacketMode.mode1400 => 175,
VoicePacketMode.mode1600 => 200,
VoicePacketMode.mode2400 => 300,
VoicePacketMode.mode3200 => 400,
};
@@ -419,8 +438,7 @@ class VoiceFetchRequest {
this.version = 3,
});
static bool isVoiceFetchRequestText(String text) =>
text.startsWith(_prefix);
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -473,9 +491,7 @@ class VoiceFetchRequest {
final requesterKey6 = parts[2];
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m'))
? 'missing'
: wantToken);
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
if (sid == null) {
return null;
@@ -602,7 +618,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
}
final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36);
@@ -616,10 +636,7 @@ String? _decodeSessionId(String token) {
}
String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
..sort();
if (sorted.isEmpty) return '';
final chunks = <String>[];
@@ -632,7 +649,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue;
}
chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
);
start = curr;
prev = curr;

View File

@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) {
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact);
}

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
}
Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {};
try {

View File

@@ -35,16 +35,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
/// Validate channel name
String? _validateName(String? value) {
final l10n = AppLocalizations.of(context)!;
final trimmedValue = value?.trim() ?? '';
if (value == null || value.trim().isEmpty) {
if (trimmedValue.isEmpty) {
return l10n.channelNameRequired;
}
if (value.length > 31) {
if (trimmedValue.length > 31) {
return l10n.channelNameTooLong;
}
if (!_isAscii(value)) {
if (!_isAscii(trimmedValue)) {
return l10n.invalidAsciiCharacters;
}
@@ -105,7 +106,8 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final isHashChannel = _nameController.text.startsWith('#');
final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog(
title: Text(l10n.addChannel),
@@ -163,8 +165,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
enabled: !_isCreating,
maxLength: 31,
validator: _validateName,
textInputAction: TextInputAction.next,
textInputAction: isHashChannel
? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}), // Rebuild to update icon
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[

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,525 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../utils/trace_node_resolver.dart';
class ContactTraceSheet extends StatefulWidget {
final Contact contact;
const ContactTraceSheet({super.key, required this.contact});
@override
State<ContactTraceSheet> createState() => _ContactTraceSheetState();
}
class _ContactTraceSheetState extends State<ContactTraceSheet> {
late final Future<_ContactTraceResult> _future;
@override
void initState() {
super.initState();
_future = _loadTrace();
}
Future<_ContactTraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final localNodes = _localNodesFromContacts(
contactsProvider,
connectionProvider: connectionProvider,
);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
if (_isCompleteTrace(trace)) {
return trace;
}
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
selfPublicKey: connectionProvider.deviceInfo.publicKey,
);
return trace;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: FutureBuilder<_ContactTraceResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 360,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return SizedBox(
height: 360,
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Failed to load trace: ${snapshot.error}'),
),
),
);
}
final trace = snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concreteNodes = routeEntries
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.toList();
final mapPoints = concreteNodes
.map((node) => LatLng(node.latitude, node.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = trace.matchedRelayNodes.whereType<MeshMapNode>();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Text(
'Trace',
style: Theme.of(context).textTheme.titleLarge,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
trace.routeHashes.isEmpty
? 'Direct route to ${widget.contact.displayName}'
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 10),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
height: 240,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: concreteNodes
.asMap()
.entries
.map(
(entry) => flutter_map.Marker(
point: LatLng(
entry.value.latitude,
entry.value.longitude,
),
width: 34,
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
concreteNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
),
),
)
.toList(),
),
],
)
: const Center(
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Relays (${relayNodes.length})',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (relayNodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this contact.',
),
),
...relayNodes.map(
(node) => ListTile(
leading: const Icon(Icons.router),
title: Text(node.name),
subtitle: Text(
'${node.publicKey.substring(0, math.min(12, node.publicKey.length))}'
'${node.latitude.toStringAsFixed(5)}, ${node.longitude.toStringAsFixed(5)}',
),
),
),
],
),
),
],
),
);
},
),
);
}
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
final entries = <_RouteDisplayEntry>[];
if (trace.sender.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.sender));
}
entries.addAll(
trace.matchedRelayNodes.asMap().entries.map((entry) {
final resolved = entry.value;
final node = resolved.node;
final hashHex = trace.routeHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
resolved: resolved,
label: node?.name ?? 'Unknown',
keyLabel: node != null ? _prefixKeyLabel(node.publicKey) : hashHex,
matchSummary: resolved.matchSummary,
);
}),
);
if (trace.recipient.node != null) {
entries.add(_RouteDisplayEntry.fromResolved(trace.recipient));
}
return entries;
}
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
bool _isCompleteTrace(_ContactTraceResult trace) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.routeHashes.isEmpty) {
return true;
}
return trace.matchedRelayNodes.every((node) => node.node != null);
}
_ContactTraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? selfPublicKey,
}) {
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(selfPublicKey),
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: _toPrefixHex(widget.contact.publicKey),
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
final routeHashes =
widget.contact.routeHasPath && widget.contact.routeHopCount > 0
? widget.contact.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toLowerCase())
.toList()
: const <String>[];
final matchedRelayNodes = routeHashes
.map(
(hash) => TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hash,
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
)
.toList();
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: matchedRelayNodes,
);
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
return key
.take(take)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
List<MeshMapNode> _localNodesFromContacts(
ContactsProvider contactsProvider, {
required ConnectionProvider connectionProvider,
}) {
final nodes = contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.toList();
final selfNode = _selfNode(connectionProvider);
if (selfNode != null) {
nodes.add(selfNode);
}
return nodes;
}
MeshMapNode? _selfNode(ConnectionProvider connectionProvider) {
final publicKey = connectionProvider.deviceInfo.publicKey;
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
if (publicKey == null || advLat == null || advLon == null) {
return null;
}
if (advLat == 0 && advLon == 0) {
return null;
}
return MeshMapNode(
type: -1,
name: connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
? connectionProvider.deviceInfo.selfName!.trim()
: 'You',
publicKey: publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase(),
latitude: advLat / 1e6,
longitude: advLon / 1e6,
updatedAtMs: DateTime.now().millisecondsSinceEpoch,
);
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
}
class _ContactTraceResult {
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> routeHashes;
final List<ResolvedTraceNode> matchedRelayNodes;
const _ContactTraceResult({
required this.sender,
required this.recipient,
required this.routeHashes,
required this.matchedRelayNodes,
});
}
class _RouteDisplayEntry {
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
});
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
final node = resolved.node!;
return _RouteDisplayEntry(
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
);
}
}

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/location_trail.dart';
import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart';
@@ -66,6 +69,31 @@ class TrailControls extends StatelessWidget {
const Divider(),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton.icon(
onPressed: () => _importTrail(context, mapProvider),
icon: const Icon(Icons.file_upload_outlined),
label: Text(l10n.importFromClipboard),
),
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.length >= 2)
OutlinedButton.icon(
onPressed: () => _exportTrail(
context,
mapProvider.currentTrail!,
customName: 'My Trail',
),
icon: const Icon(Icons.file_download_outlined),
label: Text(l10n.exportToClipboard),
),
],
),
const SizedBox(height: 16),
// Trail stats
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
@@ -104,8 +132,6 @@ class TrailControls extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Clear trail button
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
@@ -238,7 +264,17 @@ class TrailControls extends StatelessWidget {
),
],
),
title: Text(contact.displayName),
title: Row(
children: [
Expanded(child: Text(contact.displayName)),
IconButton(
tooltip: l10n.exportToClipboard,
icon: const Icon(Icons.file_download_outlined),
onPressed: () =>
_exportContactTrail(context, contact),
),
],
),
subtitle: Text(
'${contact.advertHistory.length} points',
),
@@ -266,6 +302,92 @@ class TrailControls extends StatelessWidget {
);
}
Future<void> _importTrail(
BuildContext context,
MapProvider mapProvider,
) async {
final l10n = AppLocalizations.of(context)!;
try {
final importedTrail = await GpxService.importTrailFromFile();
if (importedTrail == null || !context.mounted) {
return;
}
mapProvider.replaceCurrentTrailWithImport(importedTrail);
mapProvider.clearImportedTrail();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.locationTrail}: ${importedTrail.points.length} ${l10n.points.toLowerCase()}',
),
),
);
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.importFailed(error.toString()))),
);
}
}
Future<void> _exportTrail(
BuildContext context,
LocationTrail trail, {
String? customName,
}) async {
final l10n = AppLocalizations.of(context)!;
try {
final success = await GpxService.exportTrailToFile(
trail,
customName: customName,
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed('unable to share GPX'))),
);
}
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed(error.toString()))),
);
}
}
Future<void> _exportContactTrail(BuildContext context, Contact contact) async {
await _exportTrail(
context,
_buildContactTrail(contact),
customName: '${contact.displayName} Trail',
);
}
LocationTrail _buildContactTrail(Contact contact) {
final sortedHistory = [...contact.advertHistory]
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
return LocationTrail(
id: 'contact_${contact.publicKeyHex}',
isActive: false,
startTime: sortedHistory.first.timestamp,
endTime: sortedHistory.last.timestamp,
points: sortedHistory
.map(
(advert) => TrailPoint(
position: advert.location,
timestamp: advert.timestamp,
),
)
.toList(),
);
}
void _showClearConfirmation(
BuildContext context,
MapProvider mapProvider,

View File

@@ -403,15 +403,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
}
}
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast(
'Image fetch over ${sender.routeHopCount} hops may take a while.',
@@ -462,7 +453,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen,
contactPathLen: sender.routeEncodedPathLen,
payload: payload,
);
} catch (_) {

View File

@@ -13,6 +13,7 @@ import '../../providers/connection_provider.dart';
import '../../providers/drawing_provider.dart';
import '../../providers/voice_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../providers/app_provider.dart';
import '../drawing_minimap_preview.dart';
import '../../models/ble_packet_log.dart';
import '../../services/sar_template_service.dart';
@@ -26,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart';
import 'image_message_bubble.dart';
@@ -44,6 +46,7 @@ import 'system_message_bubble.dart';
class MessageBubble extends StatefulWidget {
final Message message;
final VoidCallback? onTap;
final VoidCallback? onReply;
final bool isHighlighted;
final VoidCallback? onNavigateToMap;
@@ -54,6 +57,7 @@ class MessageBubble extends StatefulWidget {
super.key,
required this.message,
this.onTap,
this.onReply,
this.isHighlighted = false,
this.onNavigateToMap,
this.isCompact = false,
@@ -64,6 +68,7 @@ class MessageBubble extends StatefulWidget {
}
class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false;
bool _showReceivedStats = false;
@@ -91,6 +96,74 @@ class _MessageBubbleState extends State<MessageBubble> {
});
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12);
final borderColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in matches) {
if (match.start > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, match.start),
style: baseBodyStyle,
),
);
}
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontSize: mentionFontSize,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
),
),
);
} else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
}
cursor = match.end;
}
if (cursor < text.length) {
spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle));
}
return Text.rich(TextSpan(children: spans, style: baseBodyStyle));
}
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead &&
!widget.message.isSentMessage &&
@@ -209,6 +282,15 @@ class _MessageBubbleState extends State<MessageBubble> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isOwnMessage && widget.onReply != null)
ListTile(
leading: const Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply),
onTap: () {
Navigator.pop(context);
widget.onReply?.call();
},
),
// Copy text option
ListTile(
leading: const Icon(Icons.copy),
@@ -469,6 +551,9 @@ class _MessageBubbleState extends State<MessageBubble> {
final retryCause = _retryCauseLabel(widget.message);
final retryResult = _retryResultLabel(widget.message);
final retryMode = _retryModeLabel(widget.message);
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(widget.message.id);
final rawLines = <String>[
'Message ID: ${widget.message.id}',
@@ -790,7 +875,7 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow(
context,
label: l10n.retryAttempt,
value: '${widget.message.retryAttempt}/3',
value: '${widget.message.retryAttempt}/4',
),
if (widget.message.lastRetryAt != null)
_detailRow(
@@ -809,6 +894,20 @@ class _MessageBubbleState extends State<MessageBubble> {
label: l10n.floodFallback,
value: l10n.yes,
),
if (routeMetadata?.relayName case final relayName?)
_detailRow(
context,
label: 'Relay',
value: relayName,
),
if (routeMetadata?.canonicalPath
case final routePath?)
_detailRow(
context,
label: 'Selected path',
value: routePath,
onCopy: () => copyField(routePath),
),
if (retryResult != null)
_detailRow(
context,
@@ -885,7 +984,9 @@ class _MessageBubbleState extends State<MessageBubble> {
_detailRow(
context,
label: l10n.envelope,
value: envelope != null ? 'VE3 compact' : l10n.unknown,
value: envelope != null
? 'VE3 compact'
: l10n.unknown,
),
if (voiceSession != null)
_detailRow(
@@ -1238,12 +1339,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final raw = log.rawData;
final payloadType = (raw[3] >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -1259,11 +1358,9 @@ class _MessageBubbleState extends State<MessageBubble> {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null;
final raw = log.rawData;
if (raw.length < 6) return null;
final pathLen = raw[4];
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) return null;
return decoded.pathBytes;
}
void _showDeleteConfirmation(BuildContext context) {
@@ -1545,8 +1642,15 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
if (routeMetadata != null) {
return routeMetadata.modeLabel;
}
if (message.usedFloodFallback) {
return 'Flood fallback';
return 'Flood route';
}
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
@@ -1561,9 +1665,17 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final routeLabel = routeMetadata?.modeLabel.toLowerCase();
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
if (routeLabel != null) {
return 'Delivered via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Delivered after flood fallback';
return 'Delivered after flood route';
}
if (message.retryAttempt > 0) {
return 'Delivered after retry';
@@ -1574,8 +1686,11 @@ class _MessageBubbleState extends State<MessageBubble> {
}
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
if (routeLabel != null) {
return '$routeLabel in progress';
}
if (message.usedFloodFallback) {
return 'Flood fallback in progress';
return 'Flood route in progress';
}
if (message.retryAttempt > 0) {
return 'Retry in progress';
@@ -1586,8 +1701,11 @@ class _MessageBubbleState extends State<MessageBubble> {
}
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
if (routeLabel != null) {
return 'Failed via $routeLabel';
}
if (message.usedFloodFallback) {
return 'Failed after flood fallback';
return 'Failed after flood route';
}
if (message.retryAttempt > 0) {
return 'Failed after retry attempts';
@@ -1598,6 +1716,36 @@ class _MessageBubbleState extends State<MessageBubble> {
return null;
}
Widget _buildDirectMessageStatusIndicator(
BuildContext context,
Message message,
) {
switch (message.deliveryStatus) {
case MessageDeliveryStatus.delivered:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.primary,
);
case MessageDeliveryStatus.sent:
return Icon(
Icons.done,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
case MessageDeliveryStatus.sending:
return Icon(Icons.schedule, size: 15, color: Colors.orange);
case MessageDeliveryStatus.failed:
return const Icon(Icons.error_outline, size: 15, color: Colors.red);
case MessageDeliveryStatus.received:
return Icon(
Icons.done_all,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
}
@override
Widget build(BuildContext context) {
// Display system messages with minimal styling
@@ -1615,6 +1763,7 @@ class _MessageBubbleState extends State<MessageBubble> {
}
final isSarMarker = message.isSarMarker;
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final messageFontScale = context.watch<AppProvider>().messageFontScale;
// Determine if this is own message
final connectionProvider = context.read<ConnectionProvider>();
@@ -1641,6 +1790,7 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm;
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>();
@@ -1728,6 +1878,9 @@ class _MessageBubbleState extends State<MessageBubble> {
: null;
final shouldFloatBubble = widget.isCompact;
final baseBodyStyle = Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontSize: 14 * messageFontScale);
final bubble = ConstrainedBox(
constraints: BoxConstraints(
maxWidth: shouldFloatBubble
@@ -1921,9 +2074,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded(
child: Text(
displayName,
style: Theme.of(context).textTheme.labelMedium
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage
? Theme.of(context).colorScheme.primary
: null,
@@ -2146,9 +2301,9 @@ class _MessageBubbleState extends State<MessageBubble> {
);
if (drawing == null) {
return Text(
return _buildMessageTextContent(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
baseBodyStyle,
);
}
@@ -2235,10 +2390,7 @@ class _MessageBubbleState extends State<MessageBubble> {
)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
_buildMessageTextContent(message.text, baseBodyStyle),
if (!widget.isCompact &&
!isSarMarker &&
@@ -2368,7 +2520,7 @@ class _MessageBubbleState extends State<MessageBubble> {
switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered:
statusColor = Colors.green;
statusIcon = Icons.check_circle;
statusIcon = Icons.done_all;
statusText =
recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms'
@@ -2376,6 +2528,15 @@ class _MessageBubbleState extends State<MessageBubble> {
context,
)!.delivered;
break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed:
statusColor = Colors.red;
statusIcon = Icons.cancel;
@@ -2384,7 +2545,6 @@ class _MessageBubbleState extends State<MessageBubble> {
)!.failed;
break;
case MessageDeliveryStatus.sending:
case MessageDeliveryStatus.sent:
default:
statusColor = Colors.orange;
statusIcon = Icons.schedule;
@@ -2458,33 +2618,42 @@ class _MessageBubbleState extends State<MessageBubble> {
Row(
mainAxisSize: MainAxisSize.max,
children: [
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(
message.deliveryStatus,
),
),
const SizedBox(width: 3),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
message.getLocalizedDeliveryStatus(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
if (message.isContactMessage)
_buildDirectMessageStatusIndicator(
context,
message,
)
else ...[
Icon(
getDeliveryStatusIcon(message.deliveryStatus),
size: 12,
color: getDeliveryStatusColor(
message.deliveryStatus,
),
),
),
const SizedBox(width: 3),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
message.getLocalizedDeliveryStatus(
context,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: getDeliveryStatusColor(
message.deliveryStatus,
),
fontStyle: FontStyle.italic,
),
),
),
),
],
// Show retry button for failed messages
if (message.deliveryStatus ==
MessageDeliveryStatus.failed) ...[
@@ -2570,6 +2739,7 @@ class _MessageBubbleState extends State<MessageBubble> {
context,
message: message,
isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
),
],
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
@@ -59,6 +60,7 @@ Widget buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isSarMarker,
MessageRouteMetadata? routeMetadata,
}) {
final metaColor = Theme.of(
context,
@@ -86,12 +88,13 @@ Widget buildBubbleMetaFooter(
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
} else if (!isSarMarker && message.pathLen < 255) {
} else if (!isSarMarker && _effectivePathLen(message, routeMetadata) < 255) {
final effectivePathLen = _effectivePathLen(message, routeMetadata);
items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
@@ -124,17 +127,27 @@ Widget buildBubbleMetaFooter(
);
}
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) =>
routeMetadata?.hopCount ?? message.pathLen;
Widget buildChannelHeaderPill(
BuildContext context, {
required String label,
IconData icon = Icons.campaign_outlined,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
double iconSize = 11,
double iconSpacing = 5,
TextStyle? textStyle,
}) {
final labelColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: padding,
decoration: BoxDecoration(
color: Theme.of(
context,
@@ -146,19 +159,21 @@ Widget buildChannelHeaderPill(
children: [
Icon(
icon,
size: 11,
size: iconSize,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
SizedBox(width: iconSpacing),
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
style:
textStyle ??
Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -176,5 +191,16 @@ Widget buildDirectHeaderCounterpart(
context,
label: label,
icon: Icons.alternate_email,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
iconSize: 10,
iconSpacing: 4,
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 10,
height: 1.0,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
);
}

View File

@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
@@ -185,6 +189,9 @@ Widget buildSentDirectSignalStatus(
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
@@ -203,7 +210,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabel(message),
label: hopDisplayLabelForMessage(message, routeMetadata),
color: Colors.indigo,
),
_techChip(
@@ -230,7 +237,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/3',
label: 'retry ${message.retryAttempt}/4',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
@@ -244,7 +251,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.waves,
label: 'flood fallback',
label: 'flood route',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
@@ -254,6 +261,17 @@ Widget buildSentDirectSignalStatus(
label: 'direct ACK',
color: Colors.indigo,
),
if (routeMetadata != null)
_techChip(
context,
icon: routeMetadata.mode == PathSelectionMode.nearestRouter
? Icons.router
: Icons.alt_route,
label: routeMetadata.modeLabel,
color: routeMetadata.mode == PathSelectionMode.nearestRouter
? Colors.deepPurple
: Colors.indigo,
),
],
);
}
@@ -277,6 +295,17 @@ String hopDisplayLabel(Message message) {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
String hopDisplayLabelForMessage(
Message message,
MessageRouteMetadata? routeMetadata,
) {
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return 'Direct';
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
if (effectivePathLen >= 255) return 'Unknown';
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
}
Widget _techChip(
BuildContext context, {
required IconData icon,

View File

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements
import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
@@ -9,7 +10,11 @@ import '../../models/ble_packet_log.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget {
final Message message;
@@ -32,10 +37,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize();
final storedPath = messagesProvider
.getMessageReceptionDetails(widget.message.id)
?.pathBytes;
final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
);
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null
@@ -43,22 +55,36 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult(
nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
if (_isCompleteTrace(
trace,
expectedRelayCount: math.max(0, widget.message.pathLen),
)) {
return trace;
}
final remoteNodes = await MeshMapNodesService.fetchNodes(
unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath,
preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
@@ -92,8 +118,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final trace = snapshot.data!;
final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries
.where((entry) => entry.node != null)
.map((entry) => entry.node!)
.where((entry) => entry.resolved.node != null)
.map((entry) => entry.resolved.node!)
.toList();
final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude))
@@ -267,7 +293,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}',
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
),
),
),
@@ -311,7 +337,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
final concrete = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) {
@@ -324,45 +353,52 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
if (trace.recipient != null &&
trace.recipient!.publicKey != trace.sender?.publicKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
if (trace.sender.node != null)
_RouteDisplayEntry.fromResolved(trace.sender),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
}
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key]
.toRadixString(16)
.padLeft(2, '0');
final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry(
node: entry.value,
label: entry.value?.name ?? 'Unknown',
keyLabel: entry.value != null
? _prefixKeyLabel(entry.value!.publicKey)
resolved: entry.value,
label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex,
matchSummary: entry.value.matchSummary,
);
}).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
}
final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender != null && trace.sender!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!),
...pathNodes.map(_RouteDisplayEntry.fromNode),
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromResolved(trace.sender),
...trace.matchedPathNodes
.where((entry) => entry.node != null)
.map(_RouteDisplayEntry.fromResolved),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
];
}
@@ -385,14 +421,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.toLowerCase();
}
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null;
final matches =
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first;
}
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation
.map((contact) {
@@ -427,39 +455,72 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath,
required int preferredHashSize,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
final senderNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: senderPrefix,
);
final recipientNode = TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: recipientPrefix,
);
final senderLatLng = senderNode.node == null
? null
: LatLng(senderNode.node!.latitude, senderNode.node!.longitude);
final recipientLatLng = recipientNode.node == null
? null
: LatLng(recipientNode.node!.latitude, recipientNode.node!.longitude);
if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize(
packetPath,
preferredHashSize: preferredHashSize,
);
final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath,
hashSize: hashSize,
).reversed.toList();
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
pathHashes: hopHashes,
matchedPathNodes: matched,
);
}
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
sender: senderNode.node,
recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen),
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode.node != null) senderNode,
...inferred.map(
(node) => ResolvedTraceNode(
node: node,
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
];
return _TraceResult(
@@ -472,16 +533,19 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender == null || trace.recipient == null) {
if (trace.sender.node == null || trace.recipient.node == null) {
return false;
}
if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null);
trace.matchedPathNodes.every((node) => node.node != null);
}
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.length;
return concreteCount >= expectedRelayCount + 2;
}
@@ -499,13 +563,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue;
final raw = log.rawData;
final header = raw[3];
final payloadType = (header >> 2) & 0x0F;
final pathLen = raw[4];
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != expectedPayloadType) continue;
if (decoded.hopCount != message.pathLen) continue;
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -516,38 +577,37 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
if (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData;
final pathLen = raw[4];
return raw.sublist(5, 5 + pathLen);
final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
if (decoded == null || decoded.pathBytes.isEmpty) {
return null;
}
return decoded.pathBytes;
}
List<MeshMapNode?> _matchNodesFromPathHashes({
List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes,
required List<int> pathHashes,
required Set<String> localPublicKeys,
required List<String> pathHashes,
required String? senderPrefix,
required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) {
final result = <MeshMapNode?>[];
final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
if (candidates.isEmpty) {
result.add(null);
continue;
}
List<MeshMapNode> filtered = candidates;
if (i == 0 && senderPrefix != null) {
final senderMatches = filtered
.where((n) => n.publicKey.startsWith(senderPrefix))
.toList();
if (senderMatches.isNotEmpty) filtered = senderMatches;
}
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
result.add(filtered.first);
final hashHex = pathHashes[i].toLowerCase();
result.add(
TraceNodeResolver.resolveBest(
nodes: nodes,
localPublicKeys: localPublicKeys,
prefixHex: hashHex,
preferredPrefix: i == 0
? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
referenceA: senderLatLng,
referenceB: recipientLatLng,
),
);
}
return result;
}
@@ -616,10 +676,10 @@ enum TraceMode { packetPath, hopCountInference }
class _TraceResult {
final TraceMode mode;
final MeshMapNode? sender;
final MeshMapNode? recipient;
final List<int> pathHashes;
final List<MeshMapNode?> matchedPathNodes;
final ResolvedTraceNode sender;
final ResolvedTraceNode recipient;
final List<String> pathHashes;
final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({
required this.mode,
@@ -631,21 +691,30 @@ class _TraceResult {
}
class _RouteDisplayEntry {
final MeshMapNode? node;
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
const _RouteDisplayEntry({
required this.node,
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
});
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
final node = resolved.node!;
return _RouteDisplayEntry(
node: node,
resolved: resolved,
label: node.name,
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
keyLabel: node.publicKey.substring(
0,
math.min(12, node.publicKey.length),
),
matchSummary: resolved.matchSummary,
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/app_provider.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
@@ -253,6 +255,10 @@ class _MessageInput extends StatelessWidget {
@override
Widget build(BuildContext context) {
final messageFontScale = context.watch<AppProvider>().messageFontScale;
const baseFontSize = 15.0;
final resolvedFontSize = baseFontSize * messageFontScale;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
@@ -286,12 +292,12 @@ class _MessageInput extends StatelessWidget {
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
style: TextStyle(fontSize: resolvedFontSize),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
fontSize: resolvedFontSize,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),

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